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, 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/osf-config.service.ts b/src/app/core/services/osf-config.service.ts index 8a237ddd9..7034d30b0 100644 --- a/src/app/core/services/osf-config.service.ts +++ b/src/app/core/services/osf-config.service.ts @@ -43,20 +43,24 @@ export class OSFConfigService { * On the server, this is skipped as config is only needed in the browser. */ async load(): Promise { - if (!this.config && isPlatformBrowser(this.platformId)) { + if (this.config) return; + + if (isPlatformBrowser(this.platformId)) { this.config = await lastValueFrom( this.http.get('/assets/config/config.json').pipe( shareReplay(1), catchError(() => of({} as ConfigModel)) ) ); + } else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.config = ((globalThis as any).__SSR_CONFIG__ ?? {}) as ConfigModel; + } - // Apply every key from config to environment - for (const [key, value] of Object.entries(this.config)) { - // eslint-disable-next-line - // @ts-ignore - this.environment[key] = value; - } + for (const [key, value] of Object.entries(this.config)) { + // eslint-disable-next-line + // @ts-ignore + this.environment[key] = value; } } } 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..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 @@ -40,11 +40,12 @@ 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'; 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/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/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/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/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/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/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/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/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..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 { @@ -24,6 +24,7 @@ export interface MetadataModel { }; public?: boolean; currentUserPermissions: UserPermissions[]; + registrationSupplement?: string; } export interface CustomItemMetadataRecord { 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/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/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/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/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/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/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/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/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/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/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 98% rename from src/app/features/preprints/models/preprint-json-api.models.ts rename to src/app/features/preprints/models/preprint-json-api.model.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.model.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-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 98% rename from src/app/features/preprints/models/preprint.models.ts rename to src/app/features/preprints/models/preprint.model.ts index 527c1a76d..e966e60ce 100644 --- a/src/app/features/preprints/models/preprint.models.ts +++ b/src/app/features/preprints/models/preprint.model.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/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/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/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..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 @@ -5,12 +5,14 @@ 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'; 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 { 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..907ca38d7 100644 --- a/src/app/features/profile/profile.component.ts +++ b/src/app/features/profile/profile.component.ts @@ -23,8 +23,9 @@ 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'; 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/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.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/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/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/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.spec.ts b/src/app/features/project/overview/components/overview-collections/overview-collections.component.spec.ts index cbc3f5cf2..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,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.model'; + 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/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/confirm-continue-editing-dialog/confirm-continue-editing-dialog.component.html b/src/app/features/registries/components/confirm-continue-editing-dialog/confirm-continue-editing-dialog.component.html index 8a74c891e..9608fd083 100644 --- a/src/app/features/registries/components/confirm-continue-editing-dialog/confirm-continue-editing-dialog.component.html +++ b/src/app/features/registries/components/confirm-continue-editing-dialog/confirm-continue-editing-dialog.component.html @@ -10,13 +10,13 @@ class="w-12rem btn-full-width" [label]="'common.buttons.cancel' | translate" severity="info" - (click)="dialogRef.close()" + (onClick)="dialogRef.close()" /> diff --git a/src/app/features/registries/components/confirm-continue-editing-dialog/confirm-continue-editing-dialog.component.spec.ts b/src/app/features/registries/components/confirm-continue-editing-dialog/confirm-continue-editing-dialog.component.spec.ts index d3130fff6..afb73a74e 100644 --- a/src/app/features/registries/components/confirm-continue-editing-dialog/confirm-continue-editing-dialog.component.spec.ts +++ b/src/app/features/registries/components/confirm-continue-editing-dialog/confirm-continue-editing-dialog.component.spec.ts @@ -1,46 +1,42 @@ +import { Store } from '@ngxs/store'; + import { MockProvider } from 'ng-mocks'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { of } from 'rxjs'; - import { ComponentFixture, TestBed } from '@angular/core/testing'; import { SchemaActionTrigger } from '@osf/features/registries/enums'; +import { HandleSchemaResponse } from '@osf/features/registries/store'; import { ConfirmContinueEditingDialogComponent } from './confirm-continue-editing-dialog.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideDynamicDialogRefMock } from '@testing/mocks/dynamic-dialog-ref.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('ConfirmContinueEditingDialogComponent', () => { let component: ConfirmContinueEditingDialogComponent; let fixture: ComponentFixture; - let mockDialogRef: DynamicDialogRef; - let mockDialogConfig: jest.Mocked; + let store: Store; + let dialogRef: DynamicDialogRef; const MOCK_REVISION_ID = 'test-revision-id'; - beforeEach(async () => { - mockDialogRef = { - close: jest.fn(), - } as any; - - mockDialogConfig = { - data: { revisionId: MOCK_REVISION_ID }, - } as jest.Mocked; - - await TestBed.configureTestingModule({ - imports: [ConfirmContinueEditingDialogComponent, OSFTestingModule], + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ConfirmContinueEditingDialogComponent], providers: [ - MockProvider(DynamicDialogRef, mockDialogRef), - MockProvider(DynamicDialogConfig, mockDialogConfig), - provideMockStore({ - signals: [], - }), + provideOSFCore(), + provideDynamicDialogRefMock(), + // MockProvider(DynamicDialogRef), + MockProvider(DynamicDialogConfig, { data: { revisionId: MOCK_REVISION_ID } }), + provideMockStore(), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); + dialogRef = TestBed.inject(DynamicDialogRef); fixture = TestBed.createComponent(ConfirmContinueEditingDialogComponent); component = fixture.componentInstance; fixture.detectChanges(); @@ -54,87 +50,27 @@ describe('ConfirmContinueEditingDialogComponent', () => { expect(component.isSubmitting).toBe(false); }); - it('should submit with comment', () => { - const testComment = 'Test comment'; - component.form.patchValue({ comment: testComment }); - - const mockActions = { - handleSchemaResponse: jest.fn().mockReturnValue(of({})), - }; - - Object.defineProperty(component, 'actions', { - value: mockActions, - writable: true, - }); + it('should dispatch handleSchemaResponse with comment on submit', () => { + component.form.patchValue({ comment: 'Test comment' }); component.submit(); - expect(mockActions.handleSchemaResponse).toHaveBeenCalledWith( - MOCK_REVISION_ID, - SchemaActionTrigger.AdminReject, - testComment + expect(store.dispatch).toHaveBeenCalledWith( + new HandleSchemaResponse(MOCK_REVISION_ID, SchemaActionTrigger.AdminReject, 'Test comment') ); + expect(dialogRef.close).toHaveBeenCalledWith(true); }); - it('should submit with empty comment', () => { - const mockActions = { - handleSchemaResponse: jest.fn().mockReturnValue(of({})), - }; - - Object.defineProperty(component, 'actions', { - value: mockActions, - writable: true, - }); - + it('should dispatch handleSchemaResponse with empty comment on submit', () => { component.submit(); - expect(mockActions.handleSchemaResponse).toHaveBeenCalledWith( - MOCK_REVISION_ID, - SchemaActionTrigger.AdminReject, - '' + expect(store.dispatch).toHaveBeenCalledWith( + new HandleSchemaResponse(MOCK_REVISION_ID, SchemaActionTrigger.AdminReject, '') ); }); - it('should set isSubmitting to true when submitting', () => { - const mockActions = { - handleSchemaResponse: jest.fn().mockReturnValue(of({}).pipe()), - }; - - Object.defineProperty(component, 'actions', { - value: mockActions, - writable: true, - }); - - component.submit(); - expect(mockActions.handleSchemaResponse).toHaveBeenCalled(); - }); - it('should update comment value', () => { - const testComment = 'New comment'; - component.form.patchValue({ comment: testComment }); - - expect(component.form.get('comment')?.value).toBe(testComment); - }); - - it('should handle different revision IDs', () => { - const differentRevisionId = 'different-revision-id'; - (component as any).config.data = { revisionId: differentRevisionId } as any; - - const mockActions = { - handleSchemaResponse: jest.fn().mockReturnValue(of({})), - }; - - Object.defineProperty(component, 'actions', { - value: mockActions, - writable: true, - }); - - component.submit(); - - expect(mockActions.handleSchemaResponse).toHaveBeenCalledWith( - differentRevisionId, - SchemaActionTrigger.AdminReject, - '' - ); + component.form.patchValue({ comment: 'New comment' }); + expect(component.form.get('comment')?.value).toBe('New comment'); }); }); diff --git a/src/app/features/registries/components/confirm-continue-editing-dialog/confirm-continue-editing-dialog.component.ts b/src/app/features/registries/components/confirm-continue-editing-dialog/confirm-continue-editing-dialog.component.ts index 87b307f68..b24087d4e 100644 --- a/src/app/features/registries/components/confirm-continue-editing-dialog/confirm-continue-editing-dialog.component.ts +++ b/src/app/features/registries/components/confirm-continue-editing-dialog/confirm-continue-editing-dialog.component.ts @@ -23,20 +23,16 @@ import { HandleSchemaResponse } from '../../store'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class ConfirmContinueEditingDialogComponent { - readonly dialogRef = inject(DynamicDialogRef); - private readonly fb = inject(FormBuilder); readonly config = inject(DynamicDialogConfig); - private readonly destroyRef = inject(DestroyRef); + readonly dialogRef = inject(DynamicDialogRef); + readonly destroyRef = inject(DestroyRef); + readonly fb = inject(FormBuilder); - actions = createDispatchMap({ - handleSchemaResponse: HandleSchemaResponse, - }); + actions = createDispatchMap({ handleSchemaResponse: HandleSchemaResponse }); isSubmitting = false; - form: FormGroup = this.fb.group({ - comment: [''], - }); + form: FormGroup = this.fb.group({ comment: [''] }); submit(): void { const comment = this.form.value.comment; diff --git a/src/app/features/registries/components/confirm-registration-dialog/confirm-registration-dialog.component.spec.ts b/src/app/features/registries/components/confirm-registration-dialog/confirm-registration-dialog.component.spec.ts index 3cd46f5fb..781dbc459 100644 --- a/src/app/features/registries/components/confirm-registration-dialog/confirm-registration-dialog.component.spec.ts +++ b/src/app/features/registries/components/confirm-registration-dialog/confirm-registration-dialog.component.spec.ts @@ -1,47 +1,50 @@ +import { Store } from '@ngxs/store'; + import { MockProvider } from 'ng-mocks'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { of } from 'rxjs'; +import { throwError } from 'rxjs'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { SubmitType } from '@osf/features/registries/enums'; -import { RegistriesSelectors } from '@osf/features/registries/store'; +import { RegisterDraft, RegistriesSelectors } from '@osf/features/registries/store'; import { ConfirmRegistrationDialogComponent } from './confirm-registration-dialog.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideDynamicDialogRefMock } from '@testing/mocks/dynamic-dialog-ref.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('ConfirmRegistrationDialogComponent', () => { let component: ConfirmRegistrationDialogComponent; let fixture: ComponentFixture; - let mockDialogRef: DynamicDialogRef; - let mockDialogConfig: jest.Mocked; + let store: Store; + let dialogRef: DynamicDialogRef; const MOCK_CONFIG_DATA = { draftId: 'draft-1', providerId: 'provider-1', projectId: 'project-1', - components: [], + components: [] as string[], }; - beforeEach(async () => { - mockDialogRef = { close: jest.fn() } as any; - mockDialogConfig = { data: { ...MOCK_CONFIG_DATA } } as any; - - await TestBed.configureTestingModule({ - imports: [ConfirmRegistrationDialogComponent, OSFTestingModule], + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ConfirmRegistrationDialogComponent], providers: [ - MockProvider(DynamicDialogRef, mockDialogRef), - MockProvider(DynamicDialogConfig, mockDialogConfig), + provideOSFCore(), + provideDynamicDialogRefMock(), + MockProvider(DynamicDialogConfig, { data: { ...MOCK_CONFIG_DATA } }), provideMockStore({ signals: [{ selector: RegistriesSelectors.isRegistrationSubmitting, value: false }], }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); + dialogRef = TestBed.inject(DynamicDialogRef); fixture = TestBed.createComponent(ConfirmRegistrationDialogComponent); component = fixture.componentInstance; fixture.detectChanges(); @@ -78,44 +81,60 @@ describe('ConfirmRegistrationDialogComponent', () => { expect(embargoControl?.value).toBeNull(); }); - it('should submit with immediate option and close dialog', () => { - const mockActions = { - registerDraft: jest.fn().mockReturnValue(of({})), - }; - Object.defineProperty(component, 'actions', { value: mockActions, writable: true }); - + it('should dispatch registerDraft with immediate option and close dialog', () => { component.form.get('submitOption')?.setValue(SubmitType.Public); + component.submit(); - expect(mockActions.registerDraft).toHaveBeenCalledWith( - MOCK_CONFIG_DATA.draftId, - '', - MOCK_CONFIG_DATA.providerId, - MOCK_CONFIG_DATA.projectId, - MOCK_CONFIG_DATA.components + expect(store.dispatch).toHaveBeenCalledWith( + new RegisterDraft( + MOCK_CONFIG_DATA.draftId, + '', + MOCK_CONFIG_DATA.providerId, + MOCK_CONFIG_DATA.projectId, + MOCK_CONFIG_DATA.components + ) ); - expect(mockDialogRef.close).toHaveBeenCalledWith(true); + expect(dialogRef.close).toHaveBeenCalledWith(true); }); - it('should submit with embargo and include ISO embargoDate', () => { - const mockActions = { - registerDraft: jest.fn().mockReturnValue(of({})), - }; - Object.defineProperty(component, 'actions', { value: mockActions, writable: true }); - + it('should dispatch registerDraft with embargo and include ISO embargoDate', () => { const date = new Date('2025-01-01T00:00:00Z'); component.form.get('submitOption')?.setValue(SubmitType.Embargo); component.form.get('embargoDate')?.setValue(date); component.submit(); - expect(mockActions.registerDraft).toHaveBeenCalledWith( - MOCK_CONFIG_DATA.draftId, - date.toISOString(), - MOCK_CONFIG_DATA.providerId, - MOCK_CONFIG_DATA.projectId, - MOCK_CONFIG_DATA.components + expect(store.dispatch).toHaveBeenCalledWith( + new RegisterDraft( + MOCK_CONFIG_DATA.draftId, + date.toISOString(), + MOCK_CONFIG_DATA.providerId, + MOCK_CONFIG_DATA.projectId, + MOCK_CONFIG_DATA.components + ) ); - expect(mockDialogRef.close).toHaveBeenCalledWith(true); + expect(dialogRef.close).toHaveBeenCalledWith(true); + }); + + it('should return a date 3 days in the future for minEmbargoDate', () => { + const expected = new Date(); + expected.setDate(expected.getDate() + 3); + + const result = component.minEmbargoDate(); + + expect(result.getFullYear()).toBe(expected.getFullYear()); + expect(result.getMonth()).toBe(expected.getMonth()); + expect(result.getDate()).toBe(expected.getDate()); + }); + + it('should re-enable form on submit error', () => { + (store.dispatch as jest.Mock).mockReturnValueOnce(throwError(() => new Error('fail'))); + + component.form.get('submitOption')?.setValue(SubmitType.Public); + component.submit(); + + expect(component.form.enabled).toBe(true); + expect(dialogRef.close).not.toHaveBeenCalled(); }); }); diff --git a/src/app/features/registries/components/confirm-registration-dialog/confirm-registration-dialog.component.ts b/src/app/features/registries/components/confirm-registration-dialog/confirm-registration-dialog.component.ts index 874b1896f..56ee36981 100644 --- a/src/app/features/registries/components/confirm-registration-dialog/confirm-registration-dialog.component.ts +++ b/src/app/features/registries/components/confirm-registration-dialog/confirm-registration-dialog.component.ts @@ -7,7 +7,8 @@ import { DatePicker } from 'primeng/datepicker'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; import { RadioButton } from 'primeng/radiobutton'; -import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { SubmitType } from '../../enums'; @@ -21,14 +22,13 @@ import { RegisterDraft, RegistriesSelectors } from '../../store'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class ConfirmRegistrationDialogComponent { - readonly dialogRef = inject(DynamicDialogRef); - private readonly fb = inject(FormBuilder); readonly config = inject(DynamicDialogConfig); + readonly dialogRef = inject(DynamicDialogRef); + readonly destroyRef = inject(DestroyRef); + readonly fb = inject(FormBuilder); readonly isRegistrationSubmitting = select(RegistriesSelectors.isRegistrationSubmitting); - actions = createDispatchMap({ - registerDraft: RegisterDraft, - }); + actions = createDispatchMap({ registerDraft: RegisterDraft }); SubmitType = SubmitType; showDateControl = false; minEmbargoDate = computed(() => { @@ -43,21 +43,24 @@ export class ConfirmRegistrationDialogComponent { }); constructor() { - this.form.get('submitOption')!.valueChanges.subscribe((value) => { - this.showDateControl = value === SubmitType.Embargo; - const dateControl = this.form.get('embargoDate'); + this.form + .get('submitOption') + ?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((value) => { + this.showDateControl = value === SubmitType.Embargo; + const dateControl = this.form.get('embargoDate'); - if (this.showDateControl) { - dateControl!.enable(); - dateControl!.setValidators(Validators.required); - } else { - dateControl!.disable(); - dateControl!.clearValidators(); - dateControl!.reset(); - } + if (this.showDateControl) { + dateControl!.enable(); + dateControl!.setValidators(Validators.required); + } else { + dateControl!.disable(); + dateControl!.clearValidators(); + dateControl!.reset(); + } - dateControl!.updateValueAndValidity(); - }); + dateControl!.updateValueAndValidity(); + }); } submit(): void { diff --git a/src/app/features/registries/components/custom-step/custom-step.component.spec.ts b/src/app/features/registries/components/custom-step/custom-step.component.spec.ts index 1b287987e..a354ad6b6 100644 --- a/src/app/features/registries/components/custom-step/custom-step.component.spec.ts +++ b/src/app/features/registries/components/custom-step/custom-step.component.spec.ts @@ -1,50 +1,92 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; +import { signal, WritableSignal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormGroup } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { InfoIconComponent } from '@osf/shared/components/info-icon/info-icon.component'; +import { FieldType } from '@osf/shared/enums/field-type.enum'; +import { ToastService } from '@osf/shared/services/toast.service'; +import { FileModel } from '@shared/models/files/file.model'; +import { FilePayloadJsonApi } from '@shared/models/files/file-payload-json-api.model'; +import { PageSchema } from '@shared/models/registration/page-schema.model'; -import { RegistriesSelectors } from '../../store'; +import { RegistriesSelectors, SetUpdatedFields, UpdateStepState } from '../../store'; import { FilesControlComponent } from '../files-control/files-control.component'; import { CustomStepComponent } from './custom-step.component'; import { MOCK_REGISTRIES_PAGE, MOCK_STEPS_DATA } from '@testing/mocks/registries.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; + +type StepsState = Record; describe('CustomStepComponent', () => { let component: CustomStepComponent; let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; - let mockRouter: ReturnType; + let store: Store; + let routeBuilder: ActivatedRouteMockBuilder; + let mockRouter: RouterMockType; + let toastMock: ToastServiceMockType; + let pagesSignal: WritableSignal; + let stepsStateSignal: WritableSignal; - const MOCK_PAGE = MOCK_REGISTRIES_PAGE; + function createComponent( + page: PageSchema, + stepsData: Record = {}, + stepsState: StepsState = {} + ): ComponentFixture { + pagesSignal.set([page]); + stepsStateSignal.set(stepsState); + const f = TestBed.createComponent(CustomStepComponent); + f.componentRef.setInput('stepsData', stepsData); + f.componentRef.setInput('filesLink', 'files-link'); + f.componentRef.setInput('projectId', 'project'); + f.componentRef.setInput('provider', 'provider'); + f.detectChanges(); + return f; + } - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ step: 1 }).build(); + function createPage( + questions: PageSchema['questions'] = [], + sections: PageSchema['sections'] = undefined + ): PageSchema { + return { id: 'p', title: 'P', questions, sections }; + } + + beforeEach(() => { + toastMock = ToastServiceMock.simple(); + routeBuilder = ActivatedRouteMockBuilder.create().withParams({ step: 1 }); mockRouter = RouterMockBuilder.create().withUrl('/registries/drafts/id/1').build(); + pagesSignal = signal([MOCK_REGISTRIES_PAGE]); + stepsStateSignal = signal({}); - await TestBed.configureTestingModule({ - imports: [CustomStepComponent, OSFTestingModule, ...MockComponents(InfoIconComponent, FilesControlComponent)], + TestBed.configureTestingModule({ + imports: [CustomStepComponent, ...MockComponents(InfoIconComponent, FilesControlComponent)], providers: [ - MockProvider(ActivatedRoute, mockActivatedRoute), + provideOSFCore(), + MockProvider(ToastService, toastMock), + MockProvider(ActivatedRoute, routeBuilder.build()), MockProvider(Router, mockRouter), provideMockStore({ signals: [ - { selector: RegistriesSelectors.getPagesSchema, value: [MOCK_PAGE] }, - { selector: RegistriesSelectors.getStepsState, value: {} }, + { selector: RegistriesSelectors.getPagesSchema, value: pagesSignal }, + { selector: RegistriesSelectors.getStepsState, value: stepsStateSignal }, ], }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(CustomStepComponent); component = fixture.componentInstance; - fixture.componentRef.setInput('stepsData', MOCK_STEPS_DATA); fixture.componentRef.setInput('filesLink', 'files-link'); fixture.componentRef.setInput('projectId', 'project'); @@ -57,20 +99,200 @@ describe('CustomStepComponent', () => { }); it('should initialize stepForm when page available', () => { - expect(component['stepForm']).toBeDefined(); expect(Object.keys(component['stepForm'].controls)).toContain('field1'); expect(Object.keys(component['stepForm'].controls)).toContain('field2'); }); - it('should navigate back when goBack called on first step', () => { + it('should emit back on first step', () => { const backSpy = jest.spyOn(component.back, 'emit'); component.goBack(); expect(backSpy).toHaveBeenCalled(); }); - it('should navigate next when goNext called with within pages', () => { - Object.defineProperty(component, 'pages', { value: () => [MOCK_REGISTRIES_PAGE, MOCK_REGISTRIES_PAGE] }); + it('should navigate to previous step on step > 1', () => { + component.step.set(2); + component.goBack(); + expect(mockRouter.navigate).toHaveBeenCalledWith(['../', 1], { relativeTo: expect.anything() }); + }); + + it('should navigate to next step within pages', () => { + pagesSignal.set([MOCK_REGISTRIES_PAGE, MOCK_REGISTRIES_PAGE]); component.goNext(); - expect(mockRouter.navigate).toHaveBeenCalled(); + expect(mockRouter.navigate).toHaveBeenCalledWith(['../', 2], { relativeTo: expect.anything() }); + }); + + it('should emit next on last step', () => { + const nextSpy = jest.spyOn(component.next, 'emit'); + component.step.set(1); + component.goNext(); + expect(nextSpy).toHaveBeenCalled(); + }); + + it('should dispatch updateStepState on ngOnDestroy', () => { + (store.dispatch as jest.Mock).mockClear(); + component.ngOnDestroy(); + expect(store.dispatch).toHaveBeenCalledWith(expect.any(UpdateStepState)); + }); + + it('should emit updateAction and dispatch setUpdatedFields when fields changed', () => { + const emitSpy = jest.spyOn(component.updateAction, 'emit'); + component['stepForm'].get('field1')?.setValue('changed'); + (store.dispatch as jest.Mock).mockClear(); + + component.ngOnDestroy(); + + expect(emitSpy).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new SetUpdatedFields({ field1: 'changed' })); + }); + + it('should not emit updateAction when no fields changed', () => { + const emitSpy = jest.spyOn(component.updateAction, 'emit'); + (store.dispatch as jest.Mock).mockClear(); + + component.ngOnDestroy(); + + expect(emitSpy).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(SetUpdatedFields)); + }); + + it('should skip saveStepState when form has no controls', () => { + component.stepForm = new FormGroup({}); + (store.dispatch as jest.Mock).mockClear(); + + component.ngOnDestroy(); + + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it('should attach file and emit updateAction', () => { + const emitSpy = jest.spyOn(component.updateAction, 'emit'); + const mockFile = { + id: 'new-file', + name: 'new.txt', + links: { html: 'http://html', download: 'http://dl' }, + extra: { hashes: { sha256: 'abc' } }, + } as FileModel; + + component.onAttachFile(mockFile, 'field1'); + + expect(component.attachedFiles['field1'].length).toBe(1); + expect(emitSpy).toHaveBeenCalled(); + expect(emitSpy.mock.calls[0][0]['field1'][0].file_id).toBe('new-file'); + }); + + it('should not attach duplicate file', () => { + component.attachedFiles['field1'] = [{ file_id: 'file-1', name: 'existing.txt' }]; + const emitSpy = jest.spyOn(component.updateAction, 'emit'); + + component.onAttachFile({ id: 'file-1' } as FileModel, 'field1'); + + expect(component.attachedFiles['field1'].length).toBe(1); + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('should show warning when attachment limit reached', () => { + component.attachedFiles['field1'] = Array.from({ length: 5 }, (_, i) => ({ file_id: `f-${i}`, name: `f-${i}` })); + + const mockFile = { + id: 'new', + name: 'new.txt', + links: { html: '', download: '' }, + extra: { hashes: { sha256: '', md5: '' } }, + } as FileModel; + component.onAttachFile(mockFile, 'field1'); + + expect(toastMock.showWarn).toHaveBeenCalledWith('shared.files.limitText'); + expect(component.attachedFiles['field1'].length).toBe(5); + }); + + it('should remove file and emit updateAction', () => { + const emitSpy = jest.spyOn(component.updateAction, 'emit'); + component.attachedFiles['field1'] = [ + { file_id: 'f1', name: 'a' }, + { file_id: 'f2', name: 'b' }, + ]; + + component.removeFromAttachedFiles({ file_id: 'f1', name: 'a' }, 'field1'); + + expect(component.attachedFiles['field1'].length).toBe(1); + expect(component.attachedFiles['field1'][0].file_id).toBe('f2'); + expect(emitSpy).toHaveBeenCalled(); + }); + + it('should skip non-existent questionKey', () => { + const emitSpy = jest.spyOn(component.updateAction, 'emit'); + component.removeFromAttachedFiles({ file_id: 'f1' }, 'nonexistent'); + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('should save step state and update step on route param change', () => { + (store.dispatch as jest.Mock).mockClear(); + routeBuilder.withParams({ step: 2 }); + + expect(store.dispatch).toHaveBeenCalledWith(expect.any(UpdateStepState)); + expect(component.step()).toBe(2); + }); + + it('should mark form touched when stepsState has invalid for current step', () => { + const f = createComponent(MOCK_REGISTRIES_PAGE, MOCK_STEPS_DATA, { + 1: { invalid: true, touched: true }, + }); + expect(f.componentInstance['stepForm'].get('field1')?.touched).toBe(true); + }); + + it('should initialize checkbox control with empty array default', () => { + const page = createPage([ + { id: 'q', displayText: '', responseKey: 'cbField', fieldType: FieldType.Checkbox, required: true }, + ]); + const f = createComponent(page); + expect(f.componentInstance['stepForm'].get('cbField')?.value).toEqual([]); + }); + + it('should initialize radio control with required validator', () => { + const page = createPage([ + { id: 'q', displayText: '', responseKey: 'radioField', fieldType: FieldType.Radio, required: true }, + ]); + const f = createComponent(page); + expect(f.componentInstance['stepForm'].get('radioField')?.valid).toBe(false); + }); + + it('should initialize file control and populate attachedFiles', () => { + const page = createPage([ + { id: 'q', displayText: '', responseKey: 'fileField', fieldType: FieldType.File, required: false }, + ]); + const files: FilePayloadJsonApi[] = [ + { file_id: 'f1', file_name: 'doc.pdf', file_urls: { html: '', download: '' }, file_hashes: { sha256: '' } }, + ]; + const f = createComponent(page, { fileField: files }); + + expect(f.componentInstance.attachedFiles['fileField'].length).toBe(1); + expect(f.componentInstance.attachedFiles['fileField'][0].name).toBe('doc.pdf'); + }); + + it('should skip unknown field types', () => { + const page = createPage([ + { id: 'q', displayText: '', responseKey: 'unknownField', fieldType: 'unknown' as FieldType, required: false }, + ]); + const f = createComponent(page); + expect(f.componentInstance['stepForm'].get('unknownField')).toBeNull(); + }); + + it('should include section questions', () => { + const page = createPage( + [], + [ + { + id: 's1', + title: 'S', + questions: [ + { id: 'q', displayText: '', responseKey: 'secField', fieldType: FieldType.Text, required: false }, + ], + }, + ] + ); + const f = createComponent(page, { secField: 'val' }); + + expect(f.componentInstance['stepForm'].get('secField')).toBeDefined(); + expect(f.componentInstance['stepForm'].get('secField')?.value).toBe('val'); }); }); diff --git a/src/app/features/registries/components/custom-step/custom-step.component.ts b/src/app/features/registries/components/custom-step/custom-step.component.ts index 98900e02c..357bc71b5 100644 --- a/src/app/features/registries/components/custom-step/custom-step.component.ts +++ b/src/app/features/registries/components/custom-step/custom-step.component.ts @@ -26,7 +26,7 @@ import { signal, } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormBuilder, FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; +import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { InfoIconComponent } from '@osf/shared/components/info-icon/info-icon.component'; @@ -41,28 +41,27 @@ import { FilePayloadJsonApi } from '@shared/models/files/file-payload-json-api.m import { PageSchema } from '@shared/models/registration/page-schema.model'; import { FilesMapper } from '../../mappers/files.mapper'; +import { AttachedFile } from '../../models/attached-file.model'; import { RegistriesSelectors, SetUpdatedFields, UpdateStepState } from '../../store'; import { FilesControlComponent } from '../files-control/files-control.component'; @Component({ selector: 'osf-custom-step', imports: [ + Button, Card, - Textarea, - RadioButton, - FormsModule, Checkbox, - TranslatePipe, + Chip, + Inplace, InputText, + Message, + RadioButton, + Textarea, + ReactiveFormsModule, NgTemplateOutlet, - Inplace, - TranslatePipe, InfoIconComponent, - Button, - ReactiveFormsModule, - Message, FilesControlComponent, - Chip, + TranslatePipe, ], templateUrl: './custom-step.component.html', styleUrl: './custom-step.component.scss', @@ -80,38 +79,100 @@ export class CustomStepComponent implements OnDestroy { updateAction = output>(); back = output(); next = output(); + private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly fb = inject(FormBuilder); private readonly destroyRef = inject(DestroyRef); - private toastService = inject(ToastService); + private readonly toastService = inject(ToastService); readonly pages = select(RegistriesSelectors.getPagesSchema); - readonly FieldType = FieldType; readonly stepsState = select(RegistriesSelectors.getStepsState); - readonly actions = createDispatchMap({ + private readonly actions = createDispatchMap({ updateStepState: UpdateStepState, setUpdatedFields: SetUpdatedFields, }); + readonly FieldType = FieldType; readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; step = signal(this.route.snapshot.params['step']); currentPage = computed(() => this.pages()[this.step() - 1]); - radio = null; + stepForm: FormGroup = this.fb.group({}); + attachedFiles: Record = {}; - stepForm!: FormGroup; + constructor() { + this.setupRouteWatcher(); + this.setupPageFormInit(); + } - attachedFiles: Record[]> = {}; + ngOnDestroy(): void { + this.saveStepState(); + } - constructor() { + onAttachFile(file: FileModel, questionKey: string): void { + this.attachedFiles[questionKey] = this.attachedFiles[questionKey] || []; + + if (this.attachedFiles[questionKey].some((f) => f.file_id === file.id)) { + return; + } + + if (this.attachedFiles[questionKey].length >= FILE_COUNT_ATTACHMENTS_LIMIT) { + this.toastService.showWarn('shared.files.limitText'); + return; + } + + this.attachedFiles[questionKey] = [...this.attachedFiles[questionKey], file]; + this.stepForm.patchValue({ [questionKey]: this.attachedFiles[questionKey] }); + + const otherFormValues = { ...this.stepForm.value }; + delete otherFormValues[questionKey]; + this.updateAction.emit({ + [questionKey]: this.mapFilesToPayload(this.attachedFiles[questionKey]), + ...otherFormValues, + }); + } + + removeFromAttachedFiles(file: AttachedFile, questionKey: string): void { + if (!this.attachedFiles[questionKey]) { + return; + } + + this.attachedFiles[questionKey] = this.attachedFiles[questionKey].filter((f) => f.file_id !== file.file_id); + this.stepForm.patchValue({ [questionKey]: this.attachedFiles[questionKey] }); + this.updateAction.emit({ + [questionKey]: this.mapFilesToPayload(this.attachedFiles[questionKey]), + }); + } + + goBack(): void { + const previousStep = this.step() - 1; + if (previousStep > 0) { + this.router.navigate(['../', previousStep], { relativeTo: this.route }); + } else { + this.back.emit(); + } + } + + goNext(): void { + const nextStep = this.step() + 1; + if (nextStep <= this.pages().length) { + this.router.navigate(['../', nextStep], { relativeTo: this.route }); + } else { + this.next.emit(); + } + } + + private setupRouteWatcher() { this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => { - this.updateStepState(); + this.saveStepState(); this.step.set(+params['step']); }); + } + private setupPageFormInit() { effect(() => { const page = this.currentPage(); if (page) { @@ -122,138 +183,78 @@ export class CustomStepComponent implements OnDestroy { private initStepForm(page: PageSchema): void { this.stepForm = this.fb.group({}); - let questions = page.questions || []; - if (page.sections?.length) { - questions = [...questions, ...page.sections.flatMap((section) => section.questions ?? [])]; - } - questions?.forEach((q) => { + const questions = [ + ...(page.questions || []), + ...(page.sections?.flatMap((section) => section.questions ?? []) ?? []), + ]; + + questions.forEach((q) => { const controlName = q.responseKey as string; - let control: FormControl; - - switch (q.fieldType) { - case FieldType.Text: - case FieldType.TextArea: - control = this.fb.control(this.stepsData()[controlName], { - validators: q.required ? [CustomValidators.requiredTrimmed()] : [], - }); - break; - - case FieldType.Checkbox: - control = this.fb.control(this.stepsData()[controlName] || [], { - validators: q.required ? [Validators.required] : [], - }); - break; - - case FieldType.Radio: - case FieldType.Select: - control = this.fb.control(this.stepsData()[controlName], { - validators: q.required ? [Validators.required] : [], - }); - break; - - case FieldType.File: - control = this.fb.control(this.stepsData()[controlName] || [], { - validators: q.required ? [Validators.required] : [], - }); - this.attachedFiles[controlName] = - this.stepsData()[controlName]?.map((file: FilePayloadJsonApi) => ({ ...file, name: file.file_name })) || []; - break; - - default: - return; + const control = this.createControl(q.fieldType!, controlName, q.required); + if (!control) return; + + if (q.fieldType === FieldType.File) { + this.attachedFiles[controlName] = + this.stepsData()[controlName]?.map((file: FilePayloadJsonApi) => ({ ...file, name: file.file_name })) || []; } this.stepForm.addControl(controlName, control); }); + if (this.stepsState()?.[this.step()]?.invalid) { this.stepForm.markAllAsTouched(); } } - private updateDraft() { - const changedFields = findChangedFields(this.stepForm.value, this.stepsData()); - if (Object.keys(changedFields).length > 0) { - this.actions.setUpdatedFields(changedFields); - this.updateAction.emit(this.stepForm.value); - } - } + private createControl(fieldType: FieldType, controlName: string, required: boolean): FormControl | null { + const value = this.stepsData()[controlName]; - private updateStepState() { - if (this.stepForm) { - this.updateDraft(); - this.stepForm.markAllAsTouched(); - this.actions.updateStepState(this.step(), this.stepForm.invalid, true); - } - } + switch (fieldType) { + case FieldType.Text: + case FieldType.TextArea: + return this.fb.control(value, { + validators: required ? [CustomValidators.requiredTrimmed()] : [], + }); - onAttachFile(file: FileModel, questionKey: string): void { - this.attachedFiles[questionKey] = this.attachedFiles[questionKey] || []; + case FieldType.Checkbox: + case FieldType.File: + return this.fb.control(value || [], { + validators: required ? [Validators.required] : [], + }); - if (!this.attachedFiles[questionKey].some((f) => f.file_id === file.id)) { - if (this.attachedFiles[questionKey].length >= FILE_COUNT_ATTACHMENTS_LIMIT) { - this.toastService.showWarn('shared.files.limitText'); - return; - } - this.attachedFiles[questionKey].push(file); - this.stepForm.patchValue({ - [questionKey]: [...(this.attachedFiles[questionKey] || []), file], - }); - const otherFormValues = { ...this.stepForm.value }; - delete otherFormValues[questionKey]; - this.updateAction.emit({ - [questionKey]: [ - ...this.attachedFiles[questionKey].map((f) => { - if (f.file_id) { - const { name: _, ...payload } = f; - return payload; - } - return FilesMapper.toFilePayload(f as FileModel); - }), - ], - ...otherFormValues, - }); - } - } + case FieldType.Radio: + case FieldType.Select: + return this.fb.control(value, { + validators: required ? [Validators.required] : [], + }); - removeFromAttachedFiles(file: Partial, questionKey: string): void { - if (this.attachedFiles[questionKey]) { - this.attachedFiles[questionKey] = this.attachedFiles[questionKey].filter((f) => f.file_id !== file.file_id); - this.stepForm.patchValue({ - [questionKey]: this.attachedFiles[questionKey], - }); - this.updateAction.emit({ - [questionKey]: [ - ...this.attachedFiles[questionKey].map((f) => { - if (f.file_id) { - const { name: _, ...payload } = f; - return payload; - } - return FilesMapper.toFilePayload(f as FileModel); - }), - ], - }); + default: + return null; } } - goBack(): void { - const previousStep = this.step() - 1; - if (previousStep > 0) { - this.router.navigate(['../', previousStep], { relativeTo: this.route }); - } else { - this.back.emit(); + private saveStepState() { + if (!this.stepForm.controls || !Object.keys(this.stepForm.controls).length) { + return; } - } - goNext(): void { - const nextStep = this.step() + 1; - if (nextStep <= this.pages().length) { - this.router.navigate(['../', nextStep], { relativeTo: this.route }); - } else { - this.next.emit(); + const changedFields = findChangedFields(this.stepForm.value, this.stepsData()); + if (Object.keys(changedFields).length > 0) { + this.actions.setUpdatedFields(changedFields); + this.updateAction.emit(this.stepForm.value); } + + this.stepForm.markAllAsTouched(); + this.actions.updateStepState(this.step(), this.stepForm.invalid, true); } - ngOnDestroy(): void { - this.updateStepState(); + private mapFilesToPayload(files: AttachedFile[]): FilePayloadJsonApi[] { + return files.map((f) => { + if (f.file_id) { + const { name: _, ...payload } = f; + return payload as FilePayloadJsonApi; + } + return FilesMapper.toFilePayload(f as FileModel); + }); } } diff --git a/src/app/features/registries/components/drafts/drafts.component.spec.ts b/src/app/features/registries/components/drafts/drafts.component.spec.ts index 98221ba90..7325770a2 100644 --- a/src/app/features/registries/components/drafts/drafts.component.spec.ts +++ b/src/app/features/registries/components/drafts/drafts.component.spec.ts @@ -1,76 +1,452 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; import { of } from 'rxjs'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { StepperComponent } from '@osf/shared/components/stepper/stepper.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; +import { LoaderService } from '@osf/shared/services/loader.service'; import { ContributorsSelectors } from '@osf/shared/stores/contributors'; import { SubjectsSelectors } from '@osf/shared/stores/subjects'; -import { RegistriesSelectors } from '../../store'; +import { ClearState, RegistriesSelectors } from '../../store'; import { DraftsComponent } from './drafts.component'; -import { MOCK_DRAFT_REGISTRATION, MOCK_PAGES_SCHEMA } from '@testing/mocks/registries.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { + MOCK_DRAFT_REGISTRATION, + MOCK_PAGES_SCHEMA, + MOCK_REGISTRIES_PAGE_WITH_SECTIONS, + MOCK_STEPS_DATA, +} from '@testing/mocks/registries.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { LoaderServiceMock } from '@testing/providers/loader-service.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; -describe('DraftsComponent', () => { - let component: DraftsComponent; - let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; - let mockRouter: ReturnType; +interface SetupOverrides { + routeParams?: Record; + firstChildParams?: Record | null; + routerUrl?: string; + routerEvents?: unknown; + selectorOverrides?: { selector: unknown; value: unknown }[]; +} - const MOCK_PAGES = MOCK_PAGES_SCHEMA; - const MOCK_DRAFT = MOCK_DRAFT_REGISTRATION; +function setup(overrides: SetupOverrides = {}) { + const routeBuilder = ActivatedRouteMockBuilder.create().withParams(overrides.routeParams ?? { id: 'reg-1' }); + const mockActivatedRoute = routeBuilder.build(); - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'reg-1' }).build(); - mockRouter = RouterMockBuilder.create().withUrl('/registries/drafts/reg-1/1').build(); + if (overrides.firstChildParams === null) { + (mockActivatedRoute as unknown as Record)['firstChild'] = null; + (mockActivatedRoute.snapshot as unknown as Record)['firstChild'] = null; + } else { + const childParams = overrides.firstChildParams ?? { id: 'reg-1', step: '1' }; + (mockActivatedRoute.snapshot as unknown as Record)['firstChild'] = { params: childParams }; + (mockActivatedRoute as unknown as Record)['firstChild'] = { snapshot: { params: childParams } }; + } + + const mockRouter = RouterMockBuilder.create() + .withUrl(overrides.routerUrl ?? '/registries/drafts/reg-1/1') + .build(); + if (overrides.routerEvents !== undefined) { + mockRouter.events = overrides.routerEvents as RouterMockType['events']; + } else { mockRouter.events = of(new NavigationEnd(1, '/', '/')); + } - await TestBed.configureTestingModule({ - imports: [DraftsComponent, OSFTestingModule, ...MockComponents(StepperComponent, SubHeaderComponent)], - providers: [ - MockProvider(ActivatedRoute, mockActivatedRoute), - MockProvider(Router, mockRouter), - provideMockStore({ - signals: [ - { selector: RegistriesSelectors.getPagesSchema, value: MOCK_PAGES }, - { selector: RegistriesSelectors.getDraftRegistration, value: MOCK_DRAFT }, - { selector: RegistriesSelectors.getStepsState, value: {} }, - { selector: RegistriesSelectors.getStepsData, value: {} }, - { selector: ContributorsSelectors.getContributors, value: [] }, - { selector: SubjectsSelectors.getSelectedSubjects, value: [] }, - ], - }), - ], - }).compileComponents(); + const defaultSignals: { selector: unknown; value: unknown }[] = [ + { selector: RegistriesSelectors.getPagesSchema, value: MOCK_PAGES_SCHEMA }, + { selector: RegistriesSelectors.getDraftRegistration, value: MOCK_DRAFT_REGISTRATION }, + { selector: RegistriesSelectors.getRegistrationLicense, value: { id: 'mit' } }, + { selector: RegistriesSelectors.getStepsState, value: {} }, + { selector: RegistriesSelectors.getStepsData, value: {} }, + { selector: ContributorsSelectors.getContributors, value: [{ id: 'c1' }] }, + { selector: SubjectsSelectors.getSelectedSubjects, value: [{ id: 's1' }] }, + ]; + + const signals = overrides.selectorOverrides + ? defaultSignals.map((s) => { + const override = overrides.selectorOverrides!.find((o) => o.selector === s.selector); + return override ? { ...s, value: override.value } : s; + }) + : defaultSignals; + + TestBed.configureTestingModule({ + imports: [DraftsComponent, ...MockComponents(StepperComponent, SubHeaderComponent)], + providers: [ + provideOSFCore(), + MockProvider(ActivatedRoute, mockActivatedRoute), + MockProvider(Router, mockRouter), + MockProvider(LoaderService, new LoaderServiceMock()), + provideMockStore({ signals }), + ], + }); + + const store = TestBed.inject(Store); + const fixture = TestBed.createComponent(DraftsComponent); + const component = fixture.componentInstance; + + return { + fixture, + component, + store, + mockRouter: TestBed.inject(Router) as unknown as RouterMockType, + mockActivatedRoute, + }; +} + +describe('DraftsComponent', () => { + let component: DraftsComponent; + let fixture: ComponentFixture; + let store: Store; + let mockRouter: RouterMockType; - fixture = TestBed.createComponent(DraftsComponent); - component = fixture.componentInstance; + beforeEach(() => { + const result = setup(); + fixture = result.fixture; + component = result.component; + store = result.store; + mockRouter = result.mockRouter; }); it('should create', () => { expect(component).toBeTruthy(); }); + it('should resolve registrationId from route firstChild', () => { + expect(component.registrationId).toBe('reg-1'); + }); + it('should compute isReviewPage from router url', () => { expect(component.isReviewPage).toBe(false); - const router = TestBed.inject(Router) as any; - router.url = '/registries/drafts/reg-1/review'; + (mockRouter as unknown as Record)['url'] = '/registries/drafts/reg-1/review'; expect(component.isReviewPage).toBe(true); }); it('should build steps from pages and defaults', () => { const steps = component.steps(); - expect(Array.isArray(steps)).toBe(true); expect(steps.length).toBe(3); + expect(steps[0].routeLink).toBe('metadata'); + expect(steps[1].label).toBe('Page 1'); + expect(steps[2].routeLink).toBe('review'); + }); + + it('should set currentStepIndex from route params', () => { + expect(component.currentStepIndex()).toBe(1); + }); + + it('should compute currentStep from steps and currentStepIndex', () => { expect(component.currentStep()).toBeDefined(); + expect(component.currentStep().label).toBe('Page 1'); + }); + + it('should compute isMetaDataInvalid as false when all fields present', () => { + expect(component.isMetaDataInvalid()).toBe(false); + }); + + it('should navigate and update currentStepIndex on stepChange', () => { + component.stepChange({ index: 0, label: 'Metadata', value: '' }); + + expect(component.currentStepIndex()).toBe(0); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/registries/drafts/reg-1/', 'metadata']); + }); + + it('should dispatch clearState on destroy', () => { + (store.dispatch as jest.Mock).mockClear(); + + component.ngOnDestroy(); + + expect(store.dispatch).toHaveBeenCalledWith(new ClearState()); + }); + + it('should compute isMetaDataInvalid as true when title is missing', () => { + const { component: c } = setup({ + selectorOverrides: [ + { selector: RegistriesSelectors.getDraftRegistration, value: { ...MOCK_DRAFT_REGISTRATION, title: '' } }, + ], + }); + + expect(c.isMetaDataInvalid()).toBe(true); + }); + + it('should compute isMetaDataInvalid as true when subjects are empty', () => { + const { component: c } = setup({ + selectorOverrides: [{ selector: SubjectsSelectors.getSelectedSubjects, value: [] }], + }); + + expect(c.isMetaDataInvalid()).toBe(true); + }); + + it('should set metadata step as invalid when license is missing', () => { + const { component: c } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getRegistrationLicense, value: null }], + }); + + const steps = c.steps(); + expect(steps[0].invalid).toBe(true); + }); + + it('should dispatch getDraftRegistration when draftRegistration is null', () => { + const { component: c } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getDraftRegistration, value: null }], + }); + + expect(c).toBeTruthy(); + }); + + it('should dispatch getContributors when contributors list is empty', () => { + const { component: c } = setup({ + selectorOverrides: [{ selector: ContributorsSelectors.getContributors, value: [] }], + }); + + expect(c).toBeTruthy(); + }); + + it('should dispatch getSubjects when selectedSubjects list is empty', () => { + const { component: c } = setup({ + selectorOverrides: [{ selector: SubjectsSelectors.getSelectedSubjects, value: [] }], + }); + + expect(c).toBeTruthy(); + }); + + it('should dispatch all actions when all initial data is missing', () => { + const { component: c } = setup({ + selectorOverrides: [ + { selector: RegistriesSelectors.getDraftRegistration, value: null }, + { selector: ContributorsSelectors.getContributors, value: [] }, + { selector: SubjectsSelectors.getSelectedSubjects, value: [] }, + ], + }); + + expect(c).toBeTruthy(); + }); + + it('should hide loader after schema blocks are fetched', fakeAsync(() => { + fixture.detectChanges(); + tick(); + + const loaderService = TestBed.inject(LoaderService); + expect(loaderService.hide).toHaveBeenCalled(); + })); + + it('should not fetch schema blocks when draft has no registrationSchemaId', () => { + const { fixture: f } = setup({ + selectorOverrides: [ + { + selector: RegistriesSelectors.getDraftRegistration, + value: { ...MOCK_DRAFT_REGISTRATION, registrationSchemaId: '' }, + }, + ], + }); + + f.detectChanges(); + + const loaderService = TestBed.inject(LoaderService); + expect(loaderService.hide).not.toHaveBeenCalled(); + }); + + it('should set currentStepIndex to pages.length + 1 on review navigation', () => { + const { component: c } = setup({ + routerUrl: '/registries/drafts/reg-1/review', + firstChildParams: null, + }); + + expect(c.currentStepIndex()).toBe(MOCK_PAGES_SCHEMA.length + 1); + }); + + it('should reset currentStepIndex to 0 when no step and not review', () => { + const { component: c } = setup({ + routerUrl: '/registries/drafts/reg-1/metadata', + firstChildParams: { id: 'reg-1' }, + }); + + expect(c.currentStepIndex()).toBe(0); + }); + + it('should set currentStepIndex from step param on navigation', () => { + const { component: c } = setup({ + firstChildParams: { id: 'reg-1', step: '2' }, + }); + + expect(c.currentStepIndex()).toBe(2); + }); + + it('should sync currentStepIndex to review step when on review page', () => { + const { component: c } = setup({ + routerUrl: '/registries/drafts/reg-1/review', + firstChildParams: null, + }); + + expect(c.currentStepIndex()).toBe(MOCK_PAGES_SCHEMA.length + 1); + }); + + it('should include questions from sections when building steps', () => { + const pagesWithSections = [...MOCK_PAGES_SCHEMA, MOCK_REGISTRIES_PAGE_WITH_SECTIONS]; + + const { component: c } = setup({ + selectorOverrides: [ + { selector: RegistriesSelectors.getPagesSchema, value: pagesWithSections }, + { selector: RegistriesSelectors.getStepsData, value: { field1: 'v1', field3: 'v3' } }, + ], + }); + + const steps = c.steps(); + expect(steps.length).toBe(4); + expect(steps[2].label).toBe('Page 2'); + expect(steps[2].touched).toBe(true); + }); + + it('should not mark section step as touched when no data for section questions', () => { + const pagesWithSections = [...MOCK_PAGES_SCHEMA, MOCK_REGISTRIES_PAGE_WITH_SECTIONS]; + + const { component: c } = setup({ + selectorOverrides: [ + { selector: RegistriesSelectors.getPagesSchema, value: pagesWithSections }, + { selector: RegistriesSelectors.getStepsData, value: {} }, + ], + }); + + const steps = c.steps(); + expect(steps[2].touched).toBe(false); + }); + + it('should mark step as invalid when required field has empty array', () => { + const { component: c } = setup({ + firstChildParams: { id: 'reg-1', step: '2' }, + selectorOverrides: [ + { selector: RegistriesSelectors.getStepsData, value: { field1: [], field2: 'v2' } }, + { selector: RegistriesSelectors.getStepsState, value: { 1: { invalid: true, touched: true } } }, + ], + }); + + const steps = c.steps(); + expect(steps[1].invalid).toBe(true); + }); + + it('should not mark step as invalid when required field has non-empty array', () => { + const { component: c } = setup({ + firstChildParams: { id: 'reg-1', step: '2' }, + selectorOverrides: [{ selector: RegistriesSelectors.getStepsData, value: { field1: ['item'], field2: 'v2' } }], + }); + + const steps = c.steps(); + expect(steps[1].invalid).toBe(false); + }); + + it('should not mark step as invalid when required field has truthy value', () => { + const { component: c } = setup({ + firstChildParams: { id: 'reg-1', step: '2' }, + selectorOverrides: [{ selector: RegistriesSelectors.getStepsData, value: { field1: 'value', field2: '' } }], + }); + + const steps = c.steps(); + expect(steps[1].invalid).toBe(false); + }); + + it('should mark step as invalid when required field is falsy', () => { + const { component: c } = setup({ + firstChildParams: { id: 'reg-1', step: '2' }, + selectorOverrides: [ + { selector: RegistriesSelectors.getStepsData, value: { field1: '', field2: 'v2' } }, + { selector: RegistriesSelectors.getStepsState, value: { 1: { invalid: true, touched: true } } }, + ], + }); + + const steps = c.steps(); + expect(steps[1].invalid).toBe(true); + }); + + it('should detect hasStepData with array data', () => { + const { component: c } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getStepsData, value: { field1: ['item1'] } }], + }); + + const steps = c.steps(); + expect(steps[1].touched).toBe(true); + }); + + it('should not detect hasStepData with empty array', () => { + const { component: c } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getStepsData, value: { field1: [] } }], + }); + + const steps = c.steps(); + expect(steps[1].touched).toBe(false); + }); + + it('should validate previous steps when currentStepIndex > 0', () => { + const { component: c } = setup({ + firstChildParams: { id: 'reg-1', step: '1' }, + selectorOverrides: [{ selector: RegistriesSelectors.getStepsData, value: { field1: 'v1' } }], + }); + + expect(c.currentStepIndex()).toBe(1); + expect(c).toBeTruthy(); + }); + + it('should not validate steps when currentStepIndex is 0', () => { + const { component: c } = setup({ + firstChildParams: { id: 'reg-1', step: '0' }, + routerUrl: '/registries/drafts/reg-1/metadata', + }); + + expect(c.currentStepIndex()).toBe(0); + }); + + it('should validate metadata step as invalid when license is missing', () => { + const { component: c } = setup({ + firstChildParams: { id: 'reg-1', step: '1' }, + selectorOverrides: [ + { selector: RegistriesSelectors.getRegistrationLicense, value: null }, + { selector: RegistriesSelectors.getStepsData, value: { field1: 'v1' } }, + ], + }); + + expect(c.isMetaDataInvalid()).toBe(true); + }); + + it('should validate metadata step as invalid when description is missing', () => { + const { component: c } = setup({ + firstChildParams: { id: 'reg-1', step: '1' }, + selectorOverrides: [ + { selector: RegistriesSelectors.getDraftRegistration, value: { ...MOCK_DRAFT_REGISTRATION, description: '' } }, + { selector: RegistriesSelectors.getStepsData, value: { field1: 'v1' } }, + ], + }); + + expect(c.isMetaDataInvalid()).toBe(true); + }); + + it('should default registrationId to empty string when no firstChild', () => { + const { component: c } = setup({ + routerUrl: '/registries/drafts/', + firstChildParams: null, + }); + + expect(c.registrationId).toBe(''); + }); + + it('should default currentStepIndex to 0 when step param is absent', () => { + const { component: c } = setup({ + firstChildParams: { id: 'reg-1' }, + routerUrl: '/registries/drafts/reg-1/metadata', + }); + + expect(c.currentStepIndex()).toBe(0); + }); + + it('should mark step as touched when stepsData has matching keys', () => { + const { component: c } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getStepsData, value: MOCK_STEPS_DATA }], + }); + + const steps = c.steps(); + expect(steps[1].touched).toBe(true); }); }); diff --git a/src/app/features/registries/components/drafts/drafts.component.ts b/src/app/features/registries/components/drafts/drafts.component.ts index a1d9fd448..f1579427a 100644 --- a/src/app/features/registries/components/drafts/drafts.component.ts +++ b/src/app/features/registries/components/drafts/drafts.component.ts @@ -2,7 +2,7 @@ import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { filter, tap } from 'rxjs'; +import { filter, switchMap, take } from 'rxjs'; import { ChangeDetectionStrategy, @@ -12,11 +12,10 @@ import { effect, inject, OnDestroy, - Signal, signal, untracked, } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from '@angular/router'; import { StepperComponent } from '@osf/shared/components/stepper/stepper.component'; @@ -37,7 +36,6 @@ import { ClearState, FetchDraft, FetchSchemaBlocks, RegistriesSelectors, UpdateS templateUrl: './drafts.component.html', styleUrl: './drafts.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [TranslateService], }) export class DraftsComponent implements OnDestroy { private readonly router = inject(Router); @@ -48,13 +46,12 @@ export class DraftsComponent implements OnDestroy { readonly pages = select(RegistriesSelectors.getPagesSchema); readonly draftRegistration = select(RegistriesSelectors.getDraftRegistration); - stepsState = select(RegistriesSelectors.getStepsState); - readonly stepsData = select(RegistriesSelectors.getStepsData); - selectedSubjects = select(SubjectsSelectors.getSelectedSubjects); - initialContributors = select(ContributorsSelectors.getContributors); - readonly contributors = select(ContributorsSelectors.getContributors); - readonly subjects = select(SubjectsSelectors.getSelectedSubjects); - readonly registrationLicense = select(RegistriesSelectors.getRegistrationLicense); + readonly stepsState = select(RegistriesSelectors.getStepsState); + + private readonly stepsData = select(RegistriesSelectors.getStepsData); + private readonly registrationLicense = select(RegistriesSelectors.getRegistrationLicense); + private readonly selectedSubjects = select(SubjectsSelectors.getSelectedSubjects); + private readonly contributors = select(ContributorsSelectors.getContributors); private readonly actions = createDispatchMap({ getSchemaBlocks: FetchSchemaBlocks, @@ -69,146 +66,161 @@ export class DraftsComponent implements OnDestroy { return this.router.url.includes('/review'); } - isMetaDataInvalid = computed(() => { - return ( + isMetaDataInvalid = computed( + () => !this.draftRegistration()?.title || !this.draftRegistration()?.description || !this.registrationLicense() || !this.selectedSubjects()?.length - ); - }); - - defaultSteps: StepOption[] = []; - - isLoaded = false; + ); - steps: Signal = computed(() => { + steps = computed(() => { const stepState = this.stepsState(); const stepData = this.stepsData(); - this.defaultSteps = DEFAULT_STEPS.map((step) => ({ - ...step, - label: this.translateService.instant(step.label), - invalid: stepState?.[step.index]?.invalid || false, + + const metadataStep: StepOption = { + ...DEFAULT_STEPS[0], + label: this.translateService.instant(DEFAULT_STEPS[0].label), + invalid: this.isMetaDataInvalid(), + touched: true, + }; + + const customSteps: StepOption[] = this.pages().map((page, index) => ({ + index: index + 1, + label: page.title, + value: page.id, + routeLink: `${index + 1}`, + invalid: stepState?.[index + 1]?.invalid || false, + touched: stepState?.[index + 1]?.touched || this.hasStepData(page, stepData), })); - this.defaultSteps[0].invalid = this.isMetaDataInvalid(); - this.defaultSteps[0].touched = true; - const customSteps = this.pages().map((page, index) => { - const pageStep = this.pages()[index]; - const allQuestions = this.getAllQuestions(pageStep); - const wasTouched = - allQuestions?.some((question) => { - const questionData = stepData[question.responseKey!]; - return Array.isArray(questionData) ? questionData.length : questionData; - }) || false; - return { - index: index + 1, - label: page.title, - value: page.id, - routeLink: `${index + 1}`, - invalid: stepState?.[index + 1]?.invalid || false, - touched: stepState?.[index + 1]?.touched || wasTouched, - }; - }); - return [ - this.defaultSteps[0], - ...customSteps, - { ...this.defaultSteps[1], index: customSteps.length + 1, invalid: false }, - ]; + const reviewStep: StepOption = { + ...DEFAULT_STEPS[1], + label: this.translateService.instant(DEFAULT_STEPS[1].label), + index: customSteps.length + 1, + invalid: false, + }; + + return [metadataStep, ...customSteps, reviewStep]; }); + registrationId = this.route.snapshot.firstChild?.params['id'] || ''; + currentStepIndex = signal( this.route.snapshot.firstChild?.params['step'] ? +this.route.snapshot.firstChild?.params['step'] : 0 ); currentStep = computed(() => this.steps()[this.currentStepIndex()]); - registrationId = this.route.snapshot.firstChild?.params['id'] || ''; - constructor() { + this.loadInitialData(); + this.setupSchemaLoader(); + this.setupRouteWatcher(); + this.setupReviewStepSync(); + this.setupStepValidation(); + } + + ngOnDestroy(): void { + this.actions.clearState(); + } + + stepChange(step: StepOption): void { + this.currentStepIndex.set(step.index); + this.router.navigate([`/registries/drafts/${this.registrationId}/`, this.steps()[step.index].routeLink]); + } + + private loadInitialData() { + this.loaderService.show(); + + if (!this.draftRegistration()) { + this.actions.getDraftRegistration(this.registrationId); + } + + if (!this.contributors()?.length) { + this.actions.getContributors(this.registrationId, ResourceType.DraftRegistration); + } + + if (!this.selectedSubjects()?.length) { + this.actions.getSubjects(this.registrationId, ResourceType.DraftRegistration); + } + } + + private setupSchemaLoader() { + toObservable(this.draftRegistration) + .pipe( + filter((draft) => !!draft?.registrationSchemaId), + take(1), + switchMap((draft) => this.actions.getSchemaBlocks(draft!.registrationSchemaId)), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => this.loaderService.hide()); + } + + private setupRouteWatcher() { this.router.events .pipe( - takeUntilDestroyed(this.destroyRef), - filter((event): event is NavigationEnd => event instanceof NavigationEnd) + filter((event): event is NavigationEnd => event instanceof NavigationEnd), + takeUntilDestroyed(this.destroyRef) ) .subscribe(() => { const step = this.route.firstChild?.snapshot.params['step']; if (step) { this.currentStepIndex.set(+step); } else if (this.isReviewPage) { - const reviewStepIndex = this.pages().length + 1; - this.currentStepIndex.set(reviewStepIndex); + this.currentStepIndex.set(this.pages().length + 1); } else { this.currentStepIndex.set(0); } }); + } - this.loaderService.show(); - if (!this.draftRegistration()) { - this.actions.getDraftRegistration(this.registrationId); - } - if (!this.contributors()?.length) { - this.actions.getContributors(this.registrationId, ResourceType.DraftRegistration); - } - if (!this.subjects()?.length) { - this.actions.getSubjects(this.registrationId, ResourceType.DraftRegistration); - } - effect(() => { - const registrationSchemaId = this.draftRegistration()?.registrationSchemaId; - if (registrationSchemaId && !this.isLoaded) { - this.actions - .getSchemaBlocks(registrationSchemaId || '') - .pipe( - tap(() => { - this.isLoaded = true; - this.loaderService.hide(); - }) - ) - .subscribe(); - } - }); - + private setupReviewStepSync() { effect(() => { const reviewStepIndex = this.pages().length + 1; if (this.isReviewPage) { this.currentStepIndex.set(reviewStepIndex); } }); + } + private setupStepValidation() { effect(() => { const stepState = untracked(() => this.stepsState()); - if (this.currentStepIndex() > 0) { + const currentIndex = this.currentStepIndex(); + + if (currentIndex > 0) { this.actions.updateStepState('0', this.isMetaDataInvalid(), stepState?.[0]?.touched || false); } - if (this.pages().length && this.currentStepIndex() > 0 && this.stepsData()) { - for (let i = 1; i < this.currentStepIndex(); i++) { - const pageStep = this.pages()[i - 1]; - const allQuestions = this.getAllQuestions(pageStep); - const isStepInvalid = - allQuestions?.some((question) => { - const questionData = this.stepsData()[question.responseKey!]; - return question.required && (Array.isArray(questionData) ? !questionData.length : !questionData); - }) || false; - this.actions.updateStepState(i.toString(), isStepInvalid, stepState?.[i]?.touched || false); + + if (this.pages().length && currentIndex > 0 && this.stepsData()) { + for (let i = 1; i < currentIndex; i++) { + const page = this.pages()[i - 1]; + const invalid = this.isPageInvalid(page, this.stepsData()); + this.actions.updateStepState(i.toString(), invalid, stepState?.[i]?.touched || false); } } }); } - stepChange(step: StepOption): void { - this.currentStepIndex.set(step.index); - const pageLink = this.steps()[step.index].routeLink; - this.router.navigate([`/registries/drafts/${this.registrationId}/`, pageLink]); + private getAllQuestions(page: PageSchema): Question[] { + return [...(page?.questions ?? []), ...(page?.sections?.flatMap((section) => section.questions ?? []) ?? [])]; } - private getAllQuestions(pageStep: PageSchema): Question[] { - return [ - ...(pageStep?.questions ?? []), - ...(pageStep?.sections?.flatMap((section) => section.questions ?? []) ?? []), - ]; + private hasStepData(page: PageSchema, stepData: Record): boolean { + return ( + this.getAllQuestions(page).some((question) => { + const data = stepData[question.responseKey!]; + return Array.isArray(data) ? data.length : data; + }) || false + ); } - ngOnDestroy(): void { - this.actions.clearState(); + private isPageInvalid(page: PageSchema, stepData: Record): boolean { + return ( + this.getAllQuestions(page).some((question) => { + const data = stepData[question.responseKey!]; + return question.required && (Array.isArray(data) ? !data.length : !data); + }) || false + ); } } diff --git a/src/app/features/registries/components/files-control/files-control.component.html b/src/app/features/registries/components/files-control/files-control.component.html index bf53c0a2f..8d3350ae2 100644 --- a/src/app/features/registries/components/files-control/files-control.component.html +++ b/src/app/features/registries/components/files-control/files-control.component.html @@ -13,7 +13,7 @@ severity="success" [icon]="'fas fa-plus'" [label]="'files.actions.createFolder' | translate" - (click)="createFolder()" + (onClick)="createFolder()" > @@ -24,7 +24,7 @@ severity="success" [icon]="'fas fa-upload'" [label]="'files.actions.uploadFile' | translate" - (click)="fileInput.click()" + (onClick)="fileInput.click()" > @@ -50,6 +50,8 @@ [viewOnly]="filesViewOnly()" [resourceId]="projectId()" [provider]="provider()" + [selectedFiles]="filesSelection" + (selectFile)="onFileTreeSelected($event)" (entryFileClicked)="selectFile($event)" (uploadFilesConfirmed)="uploadFiles($event)" (loadFiles)="onLoadFiles($event)" diff --git a/src/app/features/registries/components/files-control/files-control.component.spec.ts b/src/app/features/registries/components/files-control/files-control.component.spec.ts index 79257199d..e1a26b51e 100644 --- a/src/app/features/registries/components/files-control/files-control.component.spec.ts +++ b/src/app/features/registries/components/files-control/files-control.component.spec.ts @@ -1,13 +1,26 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; import { of, Subject } from 'rxjs'; +import { HttpEventType } from '@angular/common/http'; +import { signal, WritableSignal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { HelpScoutService } from '@core/services/help-scout.service'; -import { RegistriesSelectors } from '@osf/features/registries/store'; +import { + CreateFolder, + GetFiles, + RegistriesSelectors, + SetFilesIsLoading, + SetRegistriesCurrentFolder, +} from '@osf/features/registries/store'; import { FileUploadDialogComponent } from '@osf/shared/components/file-upload-dialog/file-upload-dialog.component'; +import { FilesTreeComponent } from '@osf/shared/components/files-tree/files-tree.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; +import { FILE_SIZE_LIMIT } from '@osf/shared/constants/files-limits.const'; +import { FileModel } from '@osf/shared/models/files/file.model'; +import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { FilesService } from '@osf/shared/services/files.service'; @@ -15,57 +28,73 @@ import { ToastService } from '@osf/shared/services/toast.service'; import { FilesControlComponent } from './files-control.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { CustomConfirmationServiceMockBuilder } from '@testing/providers/custom-confirmation-provider.mock'; -import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; -import { HelpScoutServiceMockFactory } from '@testing/providers/help-scout.service.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { MockComponentWithSignal } from '@testing/providers/component-provider.mock'; +import { + CustomDialogServiceMockBuilder, + CustomDialogServiceMockType, +} from '@testing/providers/custom-dialog-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMockBuilder } from '@testing/providers/toast-provider.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; -describe('Component: File Control', () => { +describe('FilesControlComponent', () => { let component: FilesControlComponent; let fixture: ComponentFixture; - let helpScoutService: HelpScoutService; - let mockFilesService: jest.Mocked; - let mockDialogService: ReturnType; - let mockToastService: ReturnType; - let mockCustomConfirmationService: ReturnType; - const currentFolder = { - links: { newFolder: '/new-folder', upload: '/upload' }, - relationships: { filesLink: '/files-link' }, - } as any; - - beforeEach(async () => { - mockFilesService = { uploadFile: jest.fn(), getFileGuid: jest.fn() } as any; + let store: Store; + let mockFilesService: { uploadFile: jest.Mock; getFileGuid: jest.Mock }; + let mockDialogService: CustomDialogServiceMockType; + let currentFolderSignal: WritableSignal; + let toastService: ToastServiceMockType; + + const CURRENT_FOLDER = { + links: { newFolder: '/new-folder', upload: '/upload', filesLink: '/files-link' }, + } as FileFolderModel; + + beforeEach(() => { + mockFilesService = { uploadFile: jest.fn(), getFileGuid: jest.fn() }; mockDialogService = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); - mockToastService = ToastServiceMockBuilder.create().build(); - mockCustomConfirmationService = CustomConfirmationServiceMockBuilder.create().build(); - helpScoutService = HelpScoutServiceMockFactory(); - - await TestBed.configureTestingModule({ - imports: [ - FilesControlComponent, - OSFTestingModule, - ...MockComponents(LoadingSpinnerComponent, FileUploadDialogComponent), - ], + currentFolderSignal = signal(CURRENT_FOLDER); + toastService = ToastServiceMock.simple(); + + TestBed.configureTestingModule({ + imports: [FilesControlComponent, ...MockComponents(LoadingSpinnerComponent, FileUploadDialogComponent)], providers: [ + provideOSFCore(), + MockProvider(ToastService, toastService), + MockProvider(CustomConfirmationService), MockProvider(FilesService, mockFilesService), MockProvider(CustomDialogService, mockDialogService), - MockProvider(ToastService, mockToastService), - MockProvider(CustomConfirmationService, mockCustomConfirmationService), - { provide: HelpScoutService, useValue: helpScoutService }, provideMockStore({ signals: [ { selector: RegistriesSelectors.getFiles, value: [] }, { selector: RegistriesSelectors.getFilesTotalCount, value: 0 }, { selector: RegistriesSelectors.isFilesLoading, value: false }, - { selector: RegistriesSelectors.getCurrentFolder, value: currentFolder }, + { selector: RegistriesSelectors.getCurrentFolder, value: currentFolderSignal }, ], }), ], - }).compileComponents(); + }).overrideComponent(FilesControlComponent, { + remove: { imports: [FilesTreeComponent] }, + add: { + imports: [ + MockComponentWithSignal('osf-files-tree', [ + 'files', + 'selectionMode', + 'totalCount', + 'storage', + 'currentFolder', + 'isLoading', + 'scrollHeight', + 'viewOnly', + 'resourceId', + 'provider', + 'selectedFiles', + ]), + ], + }, + }); - helpScoutService = TestBed.inject(HelpScoutService); + store = TestBed.inject(Store); fixture = TestBed.createComponent(FilesControlComponent); component = fixture.componentInstance; fixture.componentRef.setInput('attachedFiles', []); @@ -76,47 +105,148 @@ describe('Component: File Control', () => { fixture.detectChanges(); }); - it('should have a default value', () => { - expect(component.fileIsUploading()).toBeFalsy(); + it('should create with default signal values', () => { + expect(component).toBeTruthy(); + expect(component.fileIsUploading()).toBe(false); + expect(component.progress()).toBe(0); + expect(component.fileName()).toBe(''); }); - it('should called the helpScoutService', () => { - expect(helpScoutService.setResourceType).toHaveBeenCalledWith('files'); + it('should do nothing when no file is selected', () => { + const event = { target: { files: [] } } as unknown as Event; + const uploadSpy = jest.spyOn(component, 'uploadFiles'); + + component.onFileSelected(event); + + expect(uploadSpy).not.toHaveBeenCalled(); + }); + + it('should show warning when file exceeds size limit', () => { + const oversizedFile = new File([''], 'big.bin'); + Object.defineProperty(oversizedFile, 'size', { value: FILE_SIZE_LIMIT }); + const event = { target: { files: [oversizedFile] } } as unknown as Event; + + component.onFileSelected(event); + + expect(toastService.showWarn).toHaveBeenCalledWith('shared.files.limitText'); + }); + + it('should upload valid file', () => { + const file = new File(['data'], 'test.txt'); + const event = { target: { files: [file] } } as unknown as Event; + const uploadSpy = jest.spyOn(component, 'uploadFiles').mockImplementation(); + + component.onFileSelected(event); + + expect(uploadSpy).toHaveBeenCalledWith(file); }); - it('should open create folder dialog and trigger files update', () => { + it('should open dialog and dispatch createFolder on confirm', () => { const onClose$ = new Subject(); - (mockDialogService.open as any).mockReturnValue({ onClose: onClose$ }); - const updateSpy = jest.spyOn(component, 'updateFilesList').mockReturnValue(of(void 0)); + mockDialogService.open.mockReturnValue({ onClose: onClose$ } as any); + (store.dispatch as jest.Mock).mockClear(); component.createFolder(); expect(mockDialogService.open).toHaveBeenCalled(); onClose$.next('New Folder'); - expect(updateSpy).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new CreateFolder('/new-folder', 'New Folder')); }); - it('should upload files, update progress and select uploaded file', () => { - const file = new File(['data'], 'test.txt', { type: 'text/plain' }); - const progress = { type: 1, loaded: 50, total: 100 } as any; - const response = { type: 4, body: { data: { id: 'files/abc' } } } as any; + it('should upload file, track progress, and select uploaded file', () => { + const file = new File(['data'], 'test.txt'); + const progress = { type: HttpEventType.UploadProgress, loaded: 50, total: 100 }; + const response = { type: HttpEventType.Response, body: { data: { id: 'files/abc' } } }; - (mockFilesService.uploadFile as any).mockReturnValue(of(progress, response)); - (mockFilesService.getFileGuid as any).mockReturnValue(of({ id: 'abc' })); + mockFilesService.uploadFile.mockReturnValue(of(progress, response)); + mockFilesService.getFileGuid.mockReturnValue(of({ id: 'abc' } as FileModel)); const selectSpy = jest.spyOn(component, 'selectFile'); component.uploadFiles(file); + expect(mockFilesService.uploadFile).toHaveBeenCalledWith(file, '/upload'); - expect(selectSpy).toHaveBeenCalledWith({ id: 'abc' } as any); + expect(component.progress()).toBe(50); + expect(selectSpy).toHaveBeenCalledWith({ id: 'abc' } as FileModel); + }); + + it('should not upload when no upload link', () => { + currentFolderSignal.set({ links: {} } as FileFolderModel); + + const file = new File(['data'], 'test.txt'); + component.uploadFiles(file); + + expect(mockFilesService.uploadFile).not.toHaveBeenCalled(); }); - it('should emit attachFile when selectFile and not view-only', (done) => { - const file = { id: 'file-1' } as any; + it('should handle File array input', () => { + const file = new File(['data'], 'test.txt'); + mockFilesService.uploadFile.mockReturnValue(of({ type: HttpEventType.Sent })); + + component.uploadFiles([file]); + + expect(mockFilesService.uploadFile).toHaveBeenCalledWith(file, '/upload'); + }); + + it('should emit attachFile when not view-only', (done) => { + const file = { id: 'file-1' } as FileModel; component.attachFile.subscribe((f) => { expect(f).toEqual(file); done(); }); component.selectFile(file); }); + + it('should not emit attachFile when filesViewOnly is true', () => { + fixture.componentRef.setInput('filesViewOnly', true); + fixture.detectChanges(); + + const emitSpy = jest.spyOn(component.attachFile, 'emit'); + component.selectFile({ id: 'file-1' } as FileModel); + + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('should dispatch getFiles on onLoadFiles', () => { + (store.dispatch as jest.Mock).mockClear(); + + component.onLoadFiles({ link: '/files', page: 2 }); + + expect(store.dispatch).toHaveBeenCalledWith(new GetFiles('/files', 2)); + }); + + it('should dispatch setCurrentFolder', () => { + const folder = { id: 'folder-1' } as FileFolderModel; + (store.dispatch as jest.Mock).mockClear(); + + component.setCurrentFolder(folder); + + expect(store.dispatch).toHaveBeenCalledWith(new SetRegistriesCurrentFolder(folder)); + }); + + it('should add file to filesSelection and deduplicate', () => { + const file = { id: 'file-1' } as FileModel; + + component.onFileTreeSelected(file); + component.onFileTreeSelected(file); + + expect(component.filesSelection).toEqual([file]); + }); + + it('should not open dialog when no newFolder link', () => { + currentFolderSignal.set({ links: {} } as FileFolderModel); + + component.createFolder(); + + expect(mockDialogService.open).not.toHaveBeenCalled(); + }); + + it('should not dispatch getFiles when currentFolder has no filesLink', () => { + (store.dispatch as jest.Mock).mockClear(); + currentFolderSignal.set({ links: {} } as FileFolderModel); + fixture.detectChanges(); + + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(SetFilesIsLoading)); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(GetFiles)); + }); }); diff --git a/src/app/features/registries/components/files-control/files-control.component.ts b/src/app/features/registries/components/files-control/files-control.component.ts index ba1b578d8..423a65d45 100644 --- a/src/app/features/registries/components/files-control/files-control.component.ts +++ b/src/app/features/registries/components/files-control/files-control.component.ts @@ -5,24 +5,12 @@ import { TranslatePipe } from '@ngx-translate/core'; import { TreeDragDropService } from 'primeng/api'; import { Button } from 'primeng/button'; -import { EMPTY, filter, finalize, Observable, shareReplay, take } from 'rxjs'; +import { filter, finalize, switchMap, take } from 'rxjs'; import { HttpEventType } from '@angular/common/http'; -import { - ChangeDetectionStrategy, - Component, - DestroyRef, - effect, - inject, - input, - OnDestroy, - output, - signal, -} from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; - -import { HelpScoutService } from '@core/services/help-scout.service'; +import { ChangeDetectionStrategy, Component, DestroyRef, inject, input, output, signal } from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; + import { CreateFolderDialogComponent } from '@osf/features/files/components'; import { FileUploadDialogComponent } from '@osf/shared/components/file-upload-dialog/file-upload-dialog.component'; import { FilesTreeComponent } from '@osf/shared/components/files-tree/files-tree.component'; @@ -47,12 +35,10 @@ import { @Component({ selector: 'osf-files-control', imports: [ - FilesTreeComponent, Button, + FilesTreeComponent, LoadingSpinnerComponent, FileUploadDialogComponent, - FormsModule, - ReactiveFormsModule, TranslatePipe, ClearFileDirective, ], @@ -61,19 +47,18 @@ import { changeDetection: ChangeDetectionStrategy.OnPush, providers: [TreeDragDropService], }) -export class FilesControlComponent implements OnDestroy { +export class FilesControlComponent { attachedFiles = input.required[]>(); - attachFile = output(); filesLink = input.required(); projectId = input.required(); provider = input.required(); filesViewOnly = input(false); + attachFile = output(); private readonly filesService = inject(FilesService); private readonly customDialogService = inject(CustomDialogService); private readonly destroyRef = inject(DestroyRef); private readonly toastService = inject(ToastService); - private readonly helpScoutService = inject(HelpScoutService); readonly files = select(RegistriesSelectors.getFiles); readonly filesTotalCount = select(RegistriesSelectors.getFilesTotalCount); @@ -85,6 +70,7 @@ export class FilesControlComponent implements OnDestroy { readonly dataLoaded = signal(false); fileIsUploading = signal(false); + filesSelection: FileModel[] = []; private readonly actions = createDispatchMap({ createFolder: CreateFolder, @@ -95,44 +81,26 @@ export class FilesControlComponent implements OnDestroy { }); constructor() { - this.helpScoutService.setResourceType('files'); - effect(() => { - const filesLink = this.filesLink(); - if (filesLink) { - this.actions - .getRootFolders(filesLink) - .pipe(shareReplay(), takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { - this.dataLoaded.set(true); - }); - } - }); - - effect(() => { - const currentFolder = this.currentFolder(); - if (currentFolder) { - this.updateFilesList().subscribe(); - } - }); + this.setupRootFoldersLoader(); + this.setupCurrentFolderWatcher(); } onFileSelected(event: Event): void { const input = event.target as HTMLInputElement; const file = input.files?.[0]; - if (file && file.size >= FILE_SIZE_LIMIT) { + if (!file) return; + + if (file.size >= FILE_SIZE_LIMIT) { this.toastService.showWarn('shared.files.limitText'); return; } - if (!file) return; this.uploadFiles(file); } createFolder(): void { - const currentFolder = this.currentFolder(); - const newFolderLink = currentFolder?.links.newFolder; - + const newFolderLink = this.currentFolder()?.links.newFolder; if (!newFolderLink) return; this.customDialogService @@ -140,35 +108,18 @@ export class FilesControlComponent implements OnDestroy { header: 'files.dialogs.createFolder.title', width: '448px', }) - .onClose.pipe(filter((folderName: string) => !!folderName)) - .subscribe((folderName) => { - this.actions - .createFolder(newFolderLink, folderName) - .pipe( - take(1), - finalize(() => { - this.updateFilesList().subscribe(() => this.fileIsUploading.set(false)); - }) - ) - .subscribe(); - }); - } - - updateFilesList(): Observable { - const currentFolder = this.currentFolder(); - if (currentFolder?.links.filesLink) { - this.actions.setFilesIsLoading(true); - return this.actions.getFiles(currentFolder?.links.filesLink, 1).pipe(take(1)); - } - - return EMPTY; + .onClose.pipe( + filter((folderName: string) => !!folderName), + switchMap((folderName) => this.actions.createFolder(newFolderLink, folderName)), + finalize(() => this.fileIsUploading.set(false)), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => this.refreshFilesList()); } uploadFiles(files: File | File[]): void { - const fileArray = Array.isArray(files) ? files : [files]; - const file = fileArray[0]; - const currentFolder = this.currentFolder(); - const uploadLink = currentFolder?.links.upload; + const file = Array.isArray(files) ? files[0] : files; + const uploadLink = this.currentFolder()?.links.upload; if (!uploadLink) return; this.fileName.set(file.name); @@ -181,7 +132,7 @@ export class FilesControlComponent implements OnDestroy { finalize(() => { this.fileIsUploading.set(false); this.fileName.set(''); - this.updateFilesList(); + this.refreshFilesList(); }) ) .subscribe((event) => { @@ -189,17 +140,14 @@ export class FilesControlComponent implements OnDestroy { this.progress.set(Math.round((event.loaded / event.total) * 100)); } - if (event.type === HttpEventType.Response) { - if (event.body) { - const fileId = event?.body?.data?.id?.split('/').pop(); - if (fileId) { - this.filesService - .getFileGuid(fileId) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((file) => { - this.selectFile(file); - }); - } + if (event.type === HttpEventType.Response && event.body) { + const fileId = event.body.data?.id?.split('/').pop(); + + if (fileId) { + this.filesService + .getFileGuid(fileId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((uploadedFile) => this.selectFile(uploadedFile)); } } }); @@ -210,6 +158,11 @@ export class FilesControlComponent implements OnDestroy { this.attachFile.emit(file); } + onFileTreeSelected(file: FileModel): void { + this.filesSelection.push(file); + this.filesSelection = [...new Set(this.filesSelection)]; + } + onLoadFiles(event: { link: string; page: number }) { this.actions.getFiles(event.link, event.page); } @@ -218,7 +171,31 @@ export class FilesControlComponent implements OnDestroy { this.actions.setCurrentFolder(folder); } - ngOnDestroy(): void { - this.helpScoutService.unsetResourceType(); + private setupRootFoldersLoader() { + toObservable(this.filesLink) + .pipe( + filter((link) => !!link), + take(1), + switchMap((link) => this.actions.getRootFolders(link)), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => this.dataLoaded.set(true)); + } + + private setupCurrentFolderWatcher() { + toObservable(this.currentFolder) + .pipe( + filter((folder) => !!folder), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => this.refreshFilesList()); + } + + private refreshFilesList(): void { + const filesLink = this.currentFolder()?.links.filesLink; + if (!filesLink) return; + + this.actions.setFilesIsLoading(true); + this.actions.getFiles(filesLink, 1); } } diff --git a/src/app/features/registries/components/justification-review/justification-review.component.html b/src/app/features/registries/components/justification-review/justification-review.component.html index a282a0fcc..8cf6e62e8 100644 --- a/src/app/features/registries/components/justification-review/justification-review.component.html +++ b/src/app/features/registries/components/justification-review/justification-review.component.html @@ -60,7 +60,7 @@

{{ section.title }}

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

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

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

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

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

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

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

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

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

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

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

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

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

} -

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

+

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

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

{{ 'registries.new.steps.title' | translate }} {{ fromProject ? optionLabel="name" optionValue="id" [loading]="isProvidersLoading()" - (onChange)="onSelectProviderSchema($event.value)" class="w-6" />
diff --git a/src/app/features/registries/components/new-registration/new-registration.component.spec.ts b/src/app/features/registries/components/new-registration/new-registration.component.spec.ts index ff8f9c3ee..c06634a3a 100644 --- a/src/app/features/registries/components/new-registration/new-registration.component.spec.ts +++ b/src/app/features/registries/components/new-registration/new-registration.component.spec.ts @@ -1,46 +1,48 @@ -import { MockComponent, MockProvider } from 'ng-mocks'; +import { Store } from '@ngxs/store'; -import { of } from 'rxjs'; +import { MockComponent, MockProvider } from 'ng-mocks'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; import { UserSelectors } from '@core/store/user'; -import { RegistriesSelectors } from '@osf/features/registries/store'; +import { CreateDraft, GetProjects, GetProviderSchemas, RegistriesSelectors } from '@osf/features/registries/store'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; +import { ToastService } from '@osf/shared/services/toast.service'; +import { GetRegistryProvider } from '@shared/stores/registration-provider'; import { NewRegistrationComponent } from './new-registration.component'; import { MOCK_PROVIDER_SCHEMAS } from '@testing/mocks/registries.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('NewRegistrationComponent', () => { let component: NewRegistrationComponent; let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; - let mockRouter: ReturnType; - const PROJECTS = [{ id: 'p1', title: 'P1' }]; - const PROVIDER_SCHEMAS = MOCK_PROVIDER_SCHEMAS; + let store: Store; + let mockRouter: RouterMockType; - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create() + beforeEach(() => { + const mockActivatedRoute = ActivatedRouteMockBuilder.create() .withParams({ providerId: 'prov-1' }) .withQueryParams({ projectId: 'proj-1' }) .build(); mockRouter = RouterMockBuilder.create().withUrl('/x').build(); - await TestBed.configureTestingModule({ - imports: [NewRegistrationComponent, OSFTestingModule, MockComponent(SubHeaderComponent)], + TestBed.configureTestingModule({ + imports: [NewRegistrationComponent, MockComponent(SubHeaderComponent)], providers: [ + provideOSFCore(), MockProvider(ActivatedRoute, mockActivatedRoute), + MockProvider(ToastService), MockProvider(Router, mockRouter), provideMockStore({ signals: [ - { selector: RegistriesSelectors.getProjects, value: PROJECTS }, - { selector: RegistriesSelectors.getProviderSchemas, value: PROVIDER_SCHEMAS }, + { selector: RegistriesSelectors.getProjects, value: [{ id: 'p1', title: 'P1' }] }, + { selector: RegistriesSelectors.getProviderSchemas, value: MOCK_PROVIDER_SCHEMAS }, { selector: RegistriesSelectors.isDraftSubmitting, value: false }, { selector: RegistriesSelectors.getDraftRegistration, value: { id: 'draft-1' } }, { selector: RegistriesSelectors.isProvidersLoading, value: false }, @@ -49,8 +51,9 @@ describe('NewRegistrationComponent', () => { ], }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(NewRegistrationComponent); component = fixture.componentInstance; fixture.detectChanges(); @@ -60,44 +63,92 @@ describe('NewRegistrationComponent', () => { expect(component).toBeTruthy(); }); - it('should init with provider and project ids from route', () => { - expect(component.providerId).toBe('prov-1'); - expect(component.projectId).toBe('proj-1'); - expect(component.fromProject).toBe(true); + it('should dispatch initial data fetching on init', () => { + expect(store.dispatch).toHaveBeenCalledWith(new GetProjects('user-1', '')); + expect(store.dispatch).toHaveBeenCalledWith(new GetRegistryProvider('prov-1')); + expect(store.dispatch).toHaveBeenCalledWith(new GetProviderSchemas('prov-1')); }); - it('should default providerSchema when empty', () => { - expect(component['draftForm'].get('providerSchema')?.value).toBe('schema-1'); + it('should init fromProject as true when projectId is present', () => { + expect(component.fromProject()).toBe(true); }); - it('should update project on selection', () => { - component.onSelectProject('p1'); - expect(component['draftForm'].get('project')?.value).toBe('p1'); + it('should init form with project id from route', () => { + expect(component.draftForm.get('project')?.value).toBe('proj-1'); + }); + + it('should default providerSchema when schemas are available', () => { + expect(component.draftForm.get('providerSchema')?.value).toBe('schema-1'); }); it('should toggle fromProject and add/remove validator', () => { - component.fromProject = false; + component.fromProject.set(false); component.toggleFromProject(); - expect(component.fromProject).toBe(true); + expect(component.fromProject()).toBe(true); + expect(component.draftForm.get('project')?.validator).toBeTruthy(); + component.toggleFromProject(); - expect(component.fromProject).toBe(false); + expect(component.fromProject()).toBe(false); + expect(component.draftForm.get('project')?.validator).toBeNull(); }); - it('should create draft when form valid', () => { - const mockActions = { - createDraft: jest.fn().mockReturnValue(of({})), - } as any; - Object.defineProperty(component, 'actions', { value: mockActions }); - + it('should dispatch createDraft and navigate when form is valid', () => { component.draftForm.patchValue({ providerSchema: 'schema-1', project: 'proj-1' }); - component.fromProject = true; + component.fromProject.set(true); + (store.dispatch as jest.Mock).mockClear(); + component.createDraft(); - expect(mockActions.createDraft).toHaveBeenCalledWith({ - registrationSchemaId: 'schema-1', - provider: 'prov-1', - projectId: 'proj-1', - }); + expect(store.dispatch).toHaveBeenCalledWith( + new CreateDraft({ registrationSchemaId: 'schema-1', provider: 'prov-1', projectId: 'proj-1' }) + ); expect(mockRouter.navigate).toHaveBeenCalledWith(['/registries/drafts/', 'draft-1', 'metadata']); }); + + it('should not dispatch createDraft when form is invalid', () => { + component.draftForm.patchValue({ providerSchema: '' }); + (store.dispatch as jest.Mock).mockClear(); + + component.createDraft(); + + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(CreateDraft)); + }); + + it('should dispatch getProjects after debounced filter', fakeAsync(() => { + (store.dispatch as jest.Mock).mockClear(); + + component.onProjectFilter('abc'); + tick(300); + + expect(store.dispatch).toHaveBeenCalledWith(new GetProjects('user-1', 'abc')); + })); + + it('should not dispatch duplicate getProjects for same filter value', fakeAsync(() => { + (store.dispatch as jest.Mock).mockClear(); + + component.onProjectFilter('abc'); + tick(300); + component.onProjectFilter('abc'); + tick(300); + + const getProjectsCalls = (store.dispatch as jest.Mock).mock.calls.filter( + ([action]: [any]) => action instanceof GetProjects + ); + expect(getProjectsCalls.length).toBe(1); + })); + + it('should debounce rapid filter calls and dispatch only the last value', fakeAsync(() => { + (store.dispatch as jest.Mock).mockClear(); + + component.onProjectFilter('a'); + component.onProjectFilter('ab'); + component.onProjectFilter('abc'); + tick(300); + + const getProjectsCalls = (store.dispatch as jest.Mock).mock.calls.filter( + ([action]: [any]) => action instanceof GetProjects + ); + expect(getProjectsCalls.length).toBe(1); + expect(getProjectsCalls[0][0]).toEqual(new GetProjects('user-1', 'abc')); + })); }); diff --git a/src/app/features/registries/components/new-registration/new-registration.component.ts b/src/app/features/registries/components/new-registration/new-registration.component.ts index 952ee73f8..62e4b8e61 100644 --- a/src/app/features/registries/components/new-registration/new-registration.component.ts +++ b/src/app/features/registries/components/new-registration/new-registration.component.ts @@ -6,10 +6,10 @@ import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; import { Select } from 'primeng/select'; -import { debounceTime, distinctUntilChanged, Subject } from 'rxjs'; +import { debounceTime, distinctUntilChanged, filter, Subject, take } from 'rxjs'; -import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; @@ -32,93 +32,95 @@ export class NewRegistrationComponent { private readonly toastService = inject(ToastService); private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); - private destroyRef = inject(DestroyRef); + private readonly destroyRef = inject(DestroyRef); + readonly user = select(UserSelectors.getCurrentUser); readonly projects = select(RegistriesSelectors.getProjects); readonly providerSchemas = select(RegistriesSelectors.getProviderSchemas); readonly isDraftSubmitting = select(RegistriesSelectors.isDraftSubmitting); - readonly draftRegistration = select(RegistriesSelectors.getDraftRegistration); readonly isProvidersLoading = select(RegistriesSelectors.isProvidersLoading); readonly isProjectsLoading = select(RegistriesSelectors.isProjectsLoading); - readonly user = select(UserSelectors.getCurrentUser); - actions = createDispatchMap({ + private readonly draftRegistration = select(RegistriesSelectors.getDraftRegistration); + + private readonly actions = createDispatchMap({ getProvider: GetRegistryProvider, getProjects: GetProjects, getProviderSchemas: GetProviderSchemas, createDraft: CreateDraft, }); + private readonly providerId = this.route.snapshot.params['providerId']; + private readonly projectId = this.route.snapshot.queryParams['projectId']; + private readonly filter$ = new Subject(); - readonly providerId = this.route.snapshot.params['providerId']; - readonly projectId = this.route.snapshot.queryParams['projectId']; - - fromProject = this.projectId !== undefined; - - draftForm = this.fb.group({ + readonly fromProject = signal(this.projectId !== undefined); + readonly draftForm = this.fb.group({ providerSchema: ['', Validators.required], project: [this.projectId || ''], }); - private filter$ = new Subject(); - constructor() { - const userId = this.user()?.id; - if (userId) { - this.actions.getProjects(userId, ''); - } - this.actions.getProvider(this.providerId); - this.actions.getProviderSchemas(this.providerId); - effect(() => { - const providerSchema = this.draftForm.get('providerSchema')?.value; - if (!providerSchema) { - this.draftForm.get('providerSchema')?.setValue(this.providerSchemas()[0]?.id); - } - }); - - this.filter$ - .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) - .subscribe((value: string) => { - if (userId) { - this.actions.getProjects(userId, value); - } - }); - } - - onSelectProject(projectId: string) { - this.draftForm.patchValue({ - project: projectId, - }); + this.loadInitialData(); + this.setupDefaultSchema(); + this.setupProjectFilter(); } onProjectFilter(value: string) { this.filter$.next(value); } - onSelectProviderSchema(providerSchemaId: string) { - this.draftForm.patchValue({ - providerSchema: providerSchemaId, - }); - } - toggleFromProject() { - this.fromProject = !this.fromProject; - this.draftForm.get('project')?.setValidators(this.fromProject ? Validators.required : null); - this.draftForm.get('project')?.updateValueAndValidity(); + this.fromProject.update((v) => !v); + const projectControl = this.draftForm.get('project'); + projectControl?.setValidators(this.fromProject() ? Validators.required : null); + projectControl?.updateValueAndValidity(); } createDraft() { + if (this.draftForm.invalid) { + return; + } + const { providerSchema, project } = this.draftForm.value; - if (this.draftForm.valid) { - this.actions - .createDraft({ - registrationSchemaId: providerSchema!, - provider: this.providerId, - projectId: this.fromProject ? (project ?? undefined) : undefined, - }) - .subscribe(() => { - this.toastService.showSuccess('registries.new.createdSuccessfully'); - this.router.navigate(['/registries/drafts/', this.draftRegistration()?.id, 'metadata']); - }); + this.actions + .createDraft({ + registrationSchemaId: providerSchema!, + provider: this.providerId, + projectId: this.fromProject() ? (project ?? undefined) : undefined, + }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.toastService.showSuccess('registries.new.createdSuccessfully'); + this.router.navigate(['/registries/drafts/', this.draftRegistration()!.id, 'metadata']); + }); + } + + private loadInitialData() { + const userId = this.user()?.id; + if (userId) { + this.actions.getProjects(userId, ''); } + this.actions.getProvider(this.providerId); + this.actions.getProviderSchemas(this.providerId); + } + + private setupDefaultSchema() { + toObservable(this.providerSchemas) + .pipe( + filter((schemas) => schemas.length > 0), + take(1) + ) + .subscribe((schemas) => this.draftForm.get('providerSchema')?.setValue(schemas[0].id)); + } + + private setupProjectFilter() { + this.filter$ + .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) + .subscribe((value: string) => { + const currentUserId = this.user()?.id; + if (currentUserId) { + this.actions.getProjects(currentUserId, value); + } + }); } } diff --git a/src/app/features/registries/components/registries-metadata-step/registries-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 11741ffba..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,12 +4,11 @@ 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'; -import { Institution } from '@osf/shared/models/institutions/institutions.models'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; import { FetchResourceInstitutions, FetchUserInstitutions, @@ -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.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 }}

{ 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 fdfaac181..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,102 +5,100 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Card } from 'primeng/card'; import { Message } from 'primeng/message'; -import { ChangeDetectionStrategy, Component, effect, inject, input, untracked } 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; - } - }); - - effect(() => { - const selectedLicense = this.selectedLicense(); - if (!selectedLicense) { - return; + this.licensesLoaded.set(true); } - - this.control().patchValue({ - id: selectedLicense.id, - }); }); effect(() => { + const control = this.control(); const licenses = this.licenses(); - const selectedLicense = untracked(() => this.selectedLicense()); + const selectedLicense = this.selectedLicense(); + const defaultLicenseId = this.draftRegistration()?.defaultLicenseId; - if (!licenses.length || !selectedLicense) { + if (selectedLicense && licenses.some((l) => l.id === selectedLicense.id)) { + control.patchValue({ id: selectedLicense.id }); return; } - if (!licenses.find((license) => license.id === selectedLicense.id)) { - this.control().patchValue({ - id: null, - }); - this.control().markAsTouched(); - this.control().updateValueAndValidity(); - } + 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 }[]>; + let customConfirmationService: CustomConfirmationServiceMockType; - 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 }]); + customConfirmationService = CustomConfirmationServiceMock.simple(); - await TestBed.configureTestingModule({ + TestBed.configureTestingModule({ imports: [ RegistriesMetadataStepComponent, - OSFTestingModule, + MockModule(TextareaModule), ...MockComponents( TextInputComponent, RegistriesContributorsComponent, @@ -47,20 +64,23 @@ describe.skip('RegistriesMetadataStepComponent', () => { ), ], providers: [ + provideOSFCore(), MockProvider(ActivatedRoute, mockActivatedRoute), MockProvider(Router, mockRouter), - MockProvider(CustomConfirmationService, { confirmDelete: jest.fn() }), + MockProvider(CustomConfirmationService, customConfirmationService), 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 +90,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(customConfirmationService.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', () => { + customConfirmationService.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/registry-provider-hero/registry-provider-hero.component.spec.ts b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.spec.ts index dd8165953..bc9d6e446 100644 --- a/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.spec.ts +++ b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.spec.ts @@ -4,31 +4,58 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component'; +import { BrandService } from '@osf/shared/services/brand.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; +import { HeaderStyleService } from '@osf/shared/services/header-style.service'; +import { RegistryProviderDetails } from '@shared/models/provider/registry-provider.model'; import { RegistryProviderHeroComponent } from './registry-provider-hero.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { + CustomDialogServiceMockBuilder, + CustomDialogServiceMockType, +} from '@testing/providers/custom-dialog-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; describe('RegistryProviderHeroComponent', () => { let component: RegistryProviderHeroComponent; let fixture: ComponentFixture; - let mockCustomDialogService: ReturnType; + let mockRouter: RouterMockType; + let mockDialog: CustomDialogServiceMockType; + let mockBrandService: { applyBranding: jest.Mock; resetBranding: jest.Mock }; + let mockHeaderStyleService: { applyHeaderStyles: jest.Mock; resetToDefaults: jest.Mock }; - beforeEach(async () => { - const mockRouter = RouterMockBuilder.create().withUrl('/x').build(); - mockCustomDialogService = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); - await TestBed.configureTestingModule({ - imports: [RegistryProviderHeroComponent, OSFTestingModule, MockComponent(SearchInputComponent)], - providers: [MockProvider(Router, mockRouter), MockProvider(CustomDialogService, mockCustomDialogService)], - }).compileComponents(); + const mockProvider: RegistryProviderDetails = { + id: 'prov-1', + name: 'Provider', + descriptionHtml: '', + permissions: [], + brand: null, + iri: '', + reviewsWorkflow: '', + }; + + beforeEach(() => { + mockRouter = RouterMockBuilder.create().withUrl('/x').build(); + mockDialog = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); + mockBrandService = { applyBranding: jest.fn(), resetBranding: jest.fn() }; + mockHeaderStyleService = { applyHeaderStyles: jest.fn(), resetToDefaults: jest.fn() }; + + TestBed.configureTestingModule({ + imports: [RegistryProviderHeroComponent, MockComponent(SearchInputComponent)], + providers: [ + provideOSFCore(), + MockProvider(Router, mockRouter), + MockProvider(CustomDialogService, mockDialog), + MockProvider(BrandService, mockBrandService), + MockProvider(HeaderStyleService, mockHeaderStyleService), + ], + }); fixture = TestBed.createComponent(RegistryProviderHeroComponent); component = fixture.componentInstance; - - fixture.componentRef.setInput('provider', { id: 'prov-1', title: 'Provider', brand: undefined } as any); + fixture.componentRef.setInput('provider', mockProvider); fixture.componentRef.setInput('isProviderLoading', false); fixture.detectChanges(); }); @@ -45,24 +72,47 @@ describe('RegistryProviderHeroComponent', () => { it('should open help dialog', () => { component.openHelpDialog(); - expect(mockCustomDialogService.open).toHaveBeenCalledWith(expect.any(Function), { + expect(mockDialog.open).toHaveBeenCalledWith(expect.any(Function), { header: 'preprints.helpDialog.header', }); }); it('should navigate to create page when provider id present', () => { - const router = TestBed.inject(Router); - const navSpy = jest.spyOn(router, 'navigate'); - fixture.componentRef.setInput('provider', { id: 'prov-1', title: 'Provider', brand: undefined } as any); component.navigateToCreatePage(); - expect(navSpy).toHaveBeenCalledWith(['/registries/prov-1/new']); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/registries/prov-1/new']); }); it('should not navigate when provider id missing', () => { - const router = TestBed.inject(Router); - const navSpy = jest.spyOn(router, 'navigate'); - fixture.componentRef.setInput('provider', { id: undefined, title: 'Provider', brand: undefined } as any); + fixture.componentRef.setInput('provider', { ...mockProvider, id: undefined }); component.navigateToCreatePage(); - expect(navSpy).not.toHaveBeenCalled(); + expect(mockRouter.navigate).not.toHaveBeenCalled(); + }); + + it('should apply branding and header styles when provider has brand', () => { + const brand = { + primaryColor: '#111', + secondaryColor: '#222', + backgroundColor: '#333', + topNavLogoImageUrl: 'logo.png', + heroBackgroundImageUrl: 'hero.png', + }; + + fixture.componentRef.setInput('provider', { ...mockProvider, brand }); + fixture.detectChanges(); + + expect(mockBrandService.applyBranding).toHaveBeenCalledWith(brand); + expect(mockHeaderStyleService.applyHeaderStyles).toHaveBeenCalledWith('#ffffff', '#111', 'hero.png'); + }); + + it('should not apply branding when provider has no brand', () => { + expect(mockBrandService.applyBranding).not.toHaveBeenCalled(); + expect(mockHeaderStyleService.applyHeaderStyles).not.toHaveBeenCalled(); + }); + + it('should reset branding and header styles on destroy', () => { + component.ngOnDestroy(); + + expect(mockHeaderStyleService.resetToDefaults).toHaveBeenCalled(); + expect(mockBrandService.resetBranding).toHaveBeenCalled(); }); }); diff --git a/src/app/features/registries/components/registry-services/registry-services.component.spec.ts b/src/app/features/registries/components/registry-services/registry-services.component.spec.ts index bf13f3b1d..a5878279c 100644 --- a/src/app/features/registries/components/registry-services/registry-services.component.spec.ts +++ b/src/app/features/registries/components/registry-services/registry-services.component.spec.ts @@ -1,17 +1,21 @@ +import { MockProvider } from 'ng-mocks'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; import { RegistryServicesComponent } from './registry-services.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; describe('RegistryServicesComponent', () => { let component: RegistryServicesComponent; let fixture: ComponentFixture; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [RegistryServicesComponent, OSFTestingModule], - }).compileComponents(); + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [RegistryServicesComponent], + providers: [provideOSFCore(), MockProvider(ActivatedRoute)], + }); fixture = TestBed.createComponent(RegistryServicesComponent); component = fixture.componentInstance; diff --git a/src/app/features/registries/components/review/review.component.html b/src/app/features/registries/components/review/review.component.html index 2cd5eeba4..89eaa06cc 100644 --- a/src/app/features/registries/components/review/review.component.html +++ b/src/app/features/registries/components/review/review.component.html @@ -4,7 +4,7 @@

{{ 'navigation.metadata' | translate }}

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

-

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

+

{{ draftRegistration()?.title }}

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

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

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

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

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

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

-

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

+

{{ draftRegistration()?.description }}

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

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

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

{{ section.title }}

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

{{ section.title }}

data-test-goto-register [label]="'registries.review.register' | translate" [disabled]="registerButtonDisabled()" - (click)="confirmRegistration()" + (onClick)="confirmRegistration()" >
diff --git a/src/app/features/registries/components/review/review.component.spec.ts b/src/app/features/registries/components/review/review.component.spec.ts index 510605975..1771c7971 100644 --- a/src/app/features/registries/components/review/review.component.spec.ts +++ b/src/app/features/registries/components/review/review.component.spec.ts @@ -1,119 +1,435 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; -import { of } from 'rxjs'; +import { Subject } from 'rxjs'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; -import { RegistriesSelectors } from '@osf/features/registries/store'; +import { + ClearState, + DeleteDraft, + FetchLicenses, + FetchProjectChildren, + RegistriesSelectors, +} from '@osf/features/registries/store'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; import { LicenseDisplayComponent } from '@osf/shared/components/license-display/license-display.component'; import { RegistrationBlocksDataComponent } from '@osf/shared/components/registration-blocks-data/registration-blocks-data.component'; -import { FieldType } from '@osf/shared/enums/field-type.enum'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ToastService } from '@osf/shared/services/toast.service'; -import { ContributorsSelectors } from '@osf/shared/stores/contributors'; -import { SubjectsSelectors } from '@osf/shared/stores/subjects'; +import { + ContributorsSelectors, + GetAllContributors, + LoadMoreContributors, + ResetContributorsState, +} from '@osf/shared/stores/contributors'; +import { FetchSelectedSubjects, SubjectsSelectors } from '@osf/shared/stores/subjects'; import { ReviewComponent } from './review.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { CustomConfirmationServiceMockBuilder } from '@testing/providers/custom-confirmation-provider.mock'; -import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { + CustomConfirmationServiceMock, + CustomConfirmationServiceMockType, +} from '@testing/providers/custom-confirmation-provider.mock'; +import { + CustomDialogServiceMockBuilder, + CustomDialogServiceMockType, +} from '@testing/providers/custom-dialog-provider.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMockBuilder } from '@testing/providers/toast-provider.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; + +const DEFAULT_DRAFT = { + id: 'draft-1', + providerId: 'prov-1', + currentUserPermissions: [], + hasProject: false, + license: { options: {} }, + branchedFrom: { id: 'proj-1', type: 'nodes' }, +}; + +function createDefaultSignals(overrides: { selector: any; value: any }[] = []) { + const defaults = [ + { selector: RegistriesSelectors.getPagesSchema, value: [] }, + { selector: RegistriesSelectors.getDraftRegistration, value: DEFAULT_DRAFT }, + { selector: RegistriesSelectors.isDraftSubmitting, value: false }, + { selector: RegistriesSelectors.isDraftLoading, value: false }, + { selector: RegistriesSelectors.getStepsData, value: {} }, + { selector: RegistriesSelectors.getRegistrationComponents, value: [] }, + { selector: RegistriesSelectors.getRegistrationLicense, value: null }, + { selector: RegistriesSelectors.getRegistration, value: { id: 'new-reg-1' } }, + { selector: RegistriesSelectors.getStepsState, value: { 0: { invalid: false } } }, + { selector: RegistriesSelectors.hasDraftAdminAccess, value: true }, + { selector: ContributorsSelectors.getContributors, value: [] }, + { selector: ContributorsSelectors.isContributorsLoading, value: false }, + { selector: ContributorsSelectors.hasMoreContributors, value: false }, + { selector: SubjectsSelectors.getSelectedSubjects, value: [] }, + ]; + + return overrides.length + ? defaults.map((s) => { + const override = overrides.find((o) => o.selector === s.selector); + return override ? { ...s, value: override.value } : s; + }) + : defaults; +} + +function setup( + opts: { + selectorOverrides?: { selector: any; value: any }[]; + dialogCloseSubject?: Subject; + } = {} +) { + const mockRouter = RouterMockBuilder.create().withUrl('/registries/123/review').build(); + const mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build(); + + const dialogClose$ = opts.dialogCloseSubject ?? new Subject(); + const mockDialog = CustomDialogServiceMockBuilder.create() + .withOpen( + jest.fn().mockReturnValue({ + onClose: dialogClose$.pipe(), + close: jest.fn(), + }) + ) + .build(); + + const mockToast = ToastServiceMock.simple(); + const mockConfirmation = CustomConfirmationServiceMock.simple(); + + TestBed.configureTestingModule({ + imports: [ + ReviewComponent, + ...MockComponents(RegistrationBlocksDataComponent, ContributorsListComponent, LicenseDisplayComponent), + ], + providers: [ + provideOSFCore(), + MockProvider(ActivatedRoute, mockActivatedRoute), + MockProvider(Router, mockRouter), + MockProvider(CustomDialogService, mockDialog), + MockProvider(CustomConfirmationService, mockConfirmation), + MockProvider(ToastService, mockToast), + provideMockStore({ signals: createDefaultSignals(opts.selectorOverrides) }), + ], + }); + + const store = TestBed.inject(Store); + const fixture = TestBed.createComponent(ReviewComponent); + const component = fixture.componentInstance; + fixture.detectChanges(); + + return { fixture, component, store, mockRouter, mockDialog, mockToast, mockConfirmation, dialogClose$ }; +} describe('ReviewComponent', () => { let component: ReviewComponent; - let fixture: ComponentFixture; - let mockRouter: ReturnType; - let mockActivatedRoute: ReturnType; - let mockDialog: ReturnType; - let mockConfirm: ReturnType; - let mockToast: ReturnType; - - beforeEach(async () => { - mockRouter = RouterMockBuilder.create().withUrl('/registries/123/review').build(); - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build(); - - mockDialog = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); - mockConfirm = CustomConfirmationServiceMockBuilder.create() - .withConfirmDelete(jest.fn((opts) => opts.onConfirm && opts.onConfirm())) - .build(); - mockToast = ToastServiceMockBuilder.create().build(); - - await TestBed.configureTestingModule({ - imports: [ - ReviewComponent, - OSFTestingModule, - ...MockComponents(RegistrationBlocksDataComponent, ContributorsListComponent, LicenseDisplayComponent), - ], - providers: [ - MockProvider(Router, mockRouter), - MockProvider(ActivatedRoute, mockActivatedRoute), - MockProvider(CustomDialogService, mockDialog), - MockProvider(CustomConfirmationService, mockConfirm), - MockProvider(ToastService, mockToast), - provideMockStore({ - signals: [ - { selector: RegistriesSelectors.getPagesSchema, value: [] }, - { - selector: RegistriesSelectors.getDraftRegistration, - value: { id: 'draft-1', providerId: 'prov-1', currentUserPermissions: [], hasProject: false }, - }, - { selector: RegistriesSelectors.isDraftSubmitting, value: false }, - { selector: RegistriesSelectors.isDraftLoading, value: false }, - { selector: RegistriesSelectors.getStepsData, value: {} }, - { selector: RegistriesSelectors.getRegistrationComponents, value: [] }, - { selector: RegistriesSelectors.getRegistrationLicense, value: null }, - { selector: RegistriesSelectors.getRegistration, value: { id: 'new-reg-1' } }, - { selector: RegistriesSelectors.getStepsState, value: { 0: { invalid: false } } }, - { selector: ContributorsSelectors.getContributors, value: [] }, - { selector: SubjectsSelectors.getSelectedSubjects, value: [] }, - ], - }), - ], - }).compileComponents(); + let store: Store; + let mockRouter: RouterMockType; + let mockDialog: CustomDialogServiceMockType; + let mockToast: ToastServiceMockType; + let mockConfirmation: CustomConfirmationServiceMockType; + let dialogClose$: Subject; - fixture = TestBed.createComponent(ReviewComponent); - component = fixture.componentInstance; - fixture.detectChanges(); + beforeEach(() => { + const result = setup(); + component = result.component; + store = result.store; + mockRouter = result.mockRouter; + mockDialog = result.mockDialog; + mockToast = result.mockToast; + mockConfirmation = result.mockConfirmation; + dialogClose$ = result.dialogClose$; }); it('should create', () => { expect(component).toBeTruthy(); - expect(component.FieldType).toBe(FieldType); }); - it('should navigate back to previous step', () => { - const navSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); + it('should dispatch getContributors, getSubjects and fetchLicenses on init', () => { + expect(store.dispatch).toHaveBeenCalledWith(new GetAllContributors('draft-1', ResourceType.DraftRegistration)); + expect(store.dispatch).toHaveBeenCalledWith(new FetchSelectedSubjects('draft-1', ResourceType.DraftRegistration)); + expect(store.dispatch).toHaveBeenCalledWith(new FetchLicenses('prov-1')); + }); + + it('should navigate to previous step on goBack', () => { + const { component: c, mockRouter: router } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getPagesSchema, value: [{ id: '1' }, { id: '2' }] }], + }); + + c.goBack(); + + expect(router.navigate).toHaveBeenCalledWith( + ['../', 2], + expect.objectContaining({ relativeTo: expect.anything() }) + ); + }); + + it('should navigate to step 0 when pages is empty on goBack', () => { component.goBack(); - expect(navSpy).toHaveBeenCalledWith(['../', 0], { relativeTo: TestBed.inject(ActivatedRoute) }); + + expect(mockRouter.navigate).toHaveBeenCalledWith( + ['../', 0], + expect.objectContaining({ relativeTo: expect.anything() }) + ); }); - it('should open confirmation dialog when deleting draft and navigate on confirm', () => { - const navSpy = jest.spyOn(TestBed.inject(Router), 'navigateByUrl'); - (component as any).actions = { - ...component.actions, - deleteDraft: jest.fn().mockReturnValue(of({})), - clearState: jest.fn(), - }; + it('should dispatch deleteDraft and navigate on confirm', () => { + mockConfirmation.confirmDelete.mockImplementation(({ onConfirm }: any) => onConfirm()); + (store.dispatch as jest.Mock).mockClear(); component.deleteDraft(); - expect(mockConfirm.confirmDelete).toHaveBeenCalled(); - expect(navSpy).toHaveBeenCalledWith('/registries/prov-1/new'); + expect(store.dispatch).toHaveBeenCalledWith(new DeleteDraft('draft-1')); + expect(store.dispatch).toHaveBeenCalledWith(new ClearState()); + expect(mockRouter.navigateByUrl).toHaveBeenCalledWith('/registries/prov-1/new'); + }); + + it('should open select components dialog when components exist', () => { + const { component: c, mockDialog: dialog } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getRegistrationComponents, value: [{ id: 'comp-1' }] }], + }); + + c.confirmRegistration(); + + expect(dialog.open).toHaveBeenCalled(); + const firstCallArgs = (dialog.open as jest.Mock).mock.calls[0]; + expect(firstCallArgs[1].header).toBe('registries.review.selectComponents.title'); }); - it('should open select components dialog when components exist and chain to confirm', () => { - (component as any).components = () => ['c1', 'c2']; - (mockDialog.open as jest.Mock).mockReturnValueOnce({ onClose: of(['c1']) } as any); + it('should open confirm registration dialog when no components', () => { component.confirmRegistration(); expect(mockDialog.open).toHaveBeenCalled(); - expect((mockDialog.open as jest.Mock).mock.calls.length).toBeGreaterThan(1); + const firstCallArgs = (mockDialog.open as jest.Mock).mock.calls[0]; + expect(firstCallArgs[1].header).toBe('registries.review.confirmation.title'); + }); + + it('should show success toast and navigate on successful registration', () => { + component.openConfirmRegistrationDialog(); + dialogClose$.next(true); + + expect(mockToast.showSuccess).toHaveBeenCalledWith('registries.review.confirmation.successMessage'); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/new-reg-1/overview']); + }); + + it('should reopen select components dialog when confirm dialog closed with falsy result and components exist', () => { + const { + component: c, + mockDialog: dialog, + dialogClose$: close$, + } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getRegistrationComponents, value: [{ id: 'comp-1' }] }], + }); + + c.openConfirmRegistrationDialog(['comp-1']); + close$.next(false); + + expect(dialog.open).toHaveBeenCalledTimes(2); + }); + + it('should not navigate when confirm dialog closed with falsy result and no components', () => { + component.openConfirmRegistrationDialog(); + dialogClose$.next(false); + + expect(mockRouter.navigate).not.toHaveBeenCalled(); + }); + + it('should pass selected components from select dialog to confirm dialog', () => { + const selectClose$ = new Subject(); + const confirmClose$ = new Subject(); + let callCount = 0; + + const { component: c, mockDialog: dialog } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getRegistrationComponents, value: [{ id: 'comp-1' }] }], + }); + + (dialog.open as jest.Mock).mockImplementation(() => { + callCount++; + const subj = callCount === 1 ? selectClose$ : confirmClose$; + return { onClose: subj.pipe(), close: jest.fn() }; + }); + + c.openSelectComponentsForRegistrationDialog(); + selectClose$.next(['comp-1']); + + expect(dialog.open).toHaveBeenCalledTimes(2); + const secondCallArgs = (dialog.open as jest.Mock).mock.calls[1]; + expect(secondCallArgs[1].data.components).toEqual(['comp-1']); + }); + + it('should not open confirm dialog when select components dialog returns falsy', () => { + const selectClose$ = new Subject(); + + const { component: c, mockDialog: dialog } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getRegistrationComponents, value: [{ id: 'comp-1' }] }], + }); + + (dialog.open as jest.Mock).mockReturnValue({ + onClose: selectClose$.pipe(), + close: jest.fn(), + }); + + c.openSelectComponentsForRegistrationDialog(); + selectClose$.next(null); + + expect(dialog.open).toHaveBeenCalledTimes(1); + }); + + it('should dispatch loadMoreContributors', () => { + (store.dispatch as jest.Mock).mockClear(); + component.loadMoreContributors(); + expect(store.dispatch).toHaveBeenCalledWith(new LoadMoreContributors('draft-1', ResourceType.DraftRegistration)); + }); + + it('should dispatch resetContributorsState on destroy', () => { + (store.dispatch as jest.Mock).mockClear(); + component.ngOnDestroy(); + expect(store.dispatch).toHaveBeenCalledWith(new ResetContributorsState()); + }); + + it('should compute isDraftInvalid as false when all steps are valid', () => { + expect(component.isDraftInvalid()).toBe(false); + }); + + it('should compute isDraftInvalid as true when any step is invalid', () => { + const { component: c } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getStepsState, value: { 0: { invalid: true } } }], + }); + expect(c.isDraftInvalid()).toBe(true); + }); + + it('should compute registerButtonDisabled as false when valid and has admin access', () => { + expect(component.registerButtonDisabled()).toBe(false); + }); + + it('should compute registerButtonDisabled as true when draft is loading', () => { + const { component: c } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.isDraftLoading, value: true }], + }); + expect(c.registerButtonDisabled()).toBe(true); + }); + + it('should compute registerButtonDisabled as true when draft is invalid', () => { + const { component: c } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getStepsState, value: { 0: { invalid: true } } }], + }); + expect(c.registerButtonDisabled()).toBe(true); + }); + + it('should compute registerButtonDisabled as true when no admin access', () => { + const { component: c } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.hasDraftAdminAccess, value: false }], + }); + expect(c.registerButtonDisabled()).toBe(true); + }); + + it('should compute licenseOptionsRecord from draft license options', () => { + const { component: c } = setup({ + selectorOverrides: [ + { + selector: RegistriesSelectors.getDraftRegistration, + value: { ...DEFAULT_DRAFT, license: { options: { year: '2026', copyright: 'Test' } } }, + }, + ], + }); + expect(c.licenseOptionsRecord()).toEqual({ year: '2026', copyright: 'Test' }); + }); + + it('should compute licenseOptionsRecord as empty when no license options', () => { + expect(component.licenseOptionsRecord()).toEqual({}); + }); + + it('should pass draftId and providerId to confirm registration dialog data', () => { + component.openConfirmRegistrationDialog(); + + const callArgs = (mockDialog.open as jest.Mock).mock.calls[0]; + expect(callArgs[1].data.draftId).toBe('draft-1'); + expect(callArgs[1].data.providerId).toBe('prov-1'); + expect(callArgs[1].data.projectId).toBe('proj-1'); + }); + + it('should set projectId to null when branchedFrom type is not nodes', () => { + const { component: c, mockDialog: dialog } = setup({ + selectorOverrides: [ + { + selector: RegistriesSelectors.getDraftRegistration, + value: { ...DEFAULT_DRAFT, branchedFrom: { id: 'proj-1', type: 'registrations' } }, + }, + ], + }); + + c.openConfirmRegistrationDialog(); + + const callArgs = (dialog.open as jest.Mock).mock.calls[0]; + expect(callArgs[1].data.projectId).toBeNull(); + }); + + it('should pass components array to confirm registration dialog', () => { + component.openConfirmRegistrationDialog(['comp-1', 'comp-2']); + + const callArgs = (mockDialog.open as jest.Mock).mock.calls[0]; + expect(callArgs[1].data.components).toEqual(['comp-1', 'comp-2']); + }); + + it('should not navigate after registration when newRegistration has no id', () => { + const { + component: c, + mockRouter: router, + mockToast: toast, + dialogClose$: close$, + } = setup({ + selectorOverrides: [{ selector: RegistriesSelectors.getRegistration, value: { id: null } }], + }); + + c.openConfirmRegistrationDialog(); + close$.next(true); + + expect(toast.showSuccess).toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it('should dispatch getProjectsComponents when draft hasProject is true', () => { + const { store: s } = setup({ + selectorOverrides: [ + { + selector: RegistriesSelectors.getDraftRegistration, + value: { ...DEFAULT_DRAFT, hasProject: true }, + }, + ], + }); + + expect(s.dispatch).toHaveBeenCalledWith(new FetchProjectChildren('proj-1')); + }); + + it('should dispatch getProjectsComponents with empty string when branchedFrom has no id', () => { + const { store: s } = setup({ + selectorOverrides: [ + { + selector: RegistriesSelectors.getDraftRegistration, + value: { ...DEFAULT_DRAFT, hasProject: true, branchedFrom: null }, + }, + ], + }); + + expect(s.dispatch).toHaveBeenCalledWith(new FetchProjectChildren('')); + }); + + it('should not dispatch getProjectsComponents when isDraftSubmitting is true', () => { + const { store: s } = setup({ + selectorOverrides: [ + { selector: RegistriesSelectors.isDraftSubmitting, value: true }, + { + selector: RegistriesSelectors.getDraftRegistration, + value: { ...DEFAULT_DRAFT, hasProject: true }, + }, + ], + }); + + expect(s.dispatch).not.toHaveBeenCalledWith(expect.any(FetchProjectChildren)); }); }); diff --git a/src/app/features/registries/components/review/review.component.ts b/src/app/features/registries/components/review/review.component.ts index 0d9f2c339..bc634acbb 100644 --- a/src/app/features/registries/components/review/review.component.ts +++ b/src/app/features/registries/components/review/review.component.ts @@ -7,10 +7,19 @@ import { Card } from 'primeng/card'; import { Message } from 'primeng/message'; import { Tag } from 'primeng/tag'; -import { map, of } from 'rxjs'; +import { filter, map } from 'rxjs'; -import { ChangeDetectionStrategy, Component, computed, effect, inject, OnDestroy } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + inject, + OnDestroy, + signal, +} from '@angular/core'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; import { ENVIRONMENT } from '@core/provider/environment.provider'; @@ -18,10 +27,7 @@ import { ContributorsListComponent } from '@osf/shared/components/contributors-l import { LicenseDisplayComponent } from '@osf/shared/components/license-display/license-display.component'; import { RegistrationBlocksDataComponent } from '@osf/shared/components/registration-blocks-data/registration-blocks-data.component'; import { INPUT_VALIDATION_MESSAGES } from '@osf/shared/constants/input-validation-messages.const'; -import { FieldType } from '@osf/shared/enums/field-type.enum'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; -import { FixSpecialCharPipe } from '@osf/shared/pipes/fix-special-char.pipe'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ToastService } from '@osf/shared/services/toast.service'; @@ -33,14 +39,7 @@ import { } from '@osf/shared/stores/contributors'; import { FetchSelectedSubjects, SubjectsSelectors } from '@osf/shared/stores/subjects'; -import { - ClearState, - DeleteDraft, - FetchLicenses, - FetchProjectChildren, - RegistriesSelectors, - UpdateStepState, -} from '../../store'; +import { ClearState, DeleteDraft, FetchLicenses, FetchProjectChildren, RegistriesSelectors } from '../../store'; import { ConfirmRegistrationDialogComponent } from '../confirm-registration-dialog/confirm-registration-dialog.component'; import { SelectComponentsDialogComponent } from '../select-components-dialog/select-components-dialog.component'; @@ -55,7 +54,6 @@ import { SelectComponentsDialogComponent } from '../select-components-dialog/sel RegistrationBlocksDataComponent, ContributorsListComponent, LicenseDisplayComponent, - FixSpecialCharPipe, ], templateUrl: './review.component.html', styleUrl: './review.component.scss', @@ -67,6 +65,7 @@ export class ReviewComponent implements OnDestroy { private readonly customConfirmationService = inject(CustomConfirmationService); private readonly customDialogService = inject(CustomDialogService); private readonly toastService = inject(ToastService); + private readonly destroyRef = inject(DestroyRef); private readonly environment = inject(ENVIRONMENT); readonly pages = select(RegistriesSelectors.getPagesSchema); @@ -74,68 +73,65 @@ export class ReviewComponent implements OnDestroy { readonly isDraftSubmitting = select(RegistriesSelectors.isDraftSubmitting); readonly isDraftLoading = select(RegistriesSelectors.isDraftLoading); readonly stepsData = select(RegistriesSelectors.getStepsData); - readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; + readonly components = select(RegistriesSelectors.getRegistrationComponents); + readonly license = select(RegistriesSelectors.getRegistrationLicense); + readonly newRegistration = select(RegistriesSelectors.getRegistration); + readonly stepsState = select(RegistriesSelectors.getStepsState); readonly contributors = select(ContributorsSelectors.getContributors); readonly areContributorsLoading = select(ContributorsSelectors.isContributorsLoading); readonly hasMoreContributors = select(ContributorsSelectors.hasMoreContributors); readonly subjects = select(SubjectsSelectors.getSelectedSubjects); - readonly components = select(RegistriesSelectors.getRegistrationComponents); - readonly license = select(RegistriesSelectors.getRegistrationLicense); - readonly newRegistration = select(RegistriesSelectors.getRegistration); + readonly hasAdminAccess = select(RegistriesSelectors.hasDraftAdminAccess); - readonly FieldType = FieldType; - - actions = createDispatchMap({ + private readonly actions = createDispatchMap({ getContributors: GetAllContributors, getSubjects: FetchSelectedSubjects, deleteDraft: DeleteDraft, clearState: ClearState, getProjectsComponents: FetchProjectChildren, fetchLicenses: FetchLicenses, - updateStepState: UpdateStepState, loadMoreContributors: LoadMoreContributors, resetContributorsState: ResetContributorsState, }); - private readonly draftId = toSignal(this.route.params.pipe(map((params) => params['id'])) ?? of(undefined)); - - stepsState = select(RegistriesSelectors.getStepsState); - - isDraftInvalid = computed(() => Object.values(this.stepsState()).some((step) => step.invalid)); + readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; - licenseOptionsRecord = computed(() => (this.draftRegistration()?.license.options ?? {}) as Record); + private readonly draftId = toSignal(this.route.params.pipe(map((params) => params['id']))); - hasAdminAccess = computed(() => { - const registry = this.draftRegistration(); - if (!registry) return false; - return registry.currentUserPermissions.includes(UserPermissions.Admin); + private readonly resolvedProviderId = computed(() => { + const draft = this.draftRegistration(); + return draft ? (draft.providerId ?? this.environment.defaultProvider) : undefined; }); + private readonly componentsLoaded = signal(false); + + isDraftInvalid = computed(() => Object.values(this.stepsState()).some((step) => step.invalid)); + licenseOptionsRecord = computed(() => (this.draftRegistration()?.license.options ?? {}) as Record); registerButtonDisabled = computed(() => this.isDraftLoading() || this.isDraftInvalid() || !this.hasAdminAccess()); constructor() { if (!this.contributors()?.length) { this.actions.getContributors(this.draftId(), ResourceType.DraftRegistration); } + if (!this.subjects()?.length) { this.actions.getSubjects(this.draftId(), ResourceType.DraftRegistration); } effect(() => { - if (this.draftRegistration()) { - this.actions.fetchLicenses(this.draftRegistration()?.providerId ?? this.environment.defaultProvider); + const providerId = this.resolvedProviderId(); + + if (providerId) { + this.actions.fetchLicenses(providerId); } }); - let componentsLoaded = false; effect(() => { - if (!this.isDraftSubmitting()) { - const draftRegistrations = this.draftRegistration(); - if (draftRegistrations?.hasProject) { - if (!componentsLoaded) { - this.actions.getProjectsComponents(draftRegistrations?.branchedFrom?.id ?? ''); - componentsLoaded = true; - } + if (!this.isDraftSubmitting() && !this.componentsLoaded()) { + const draft = this.draftRegistration(); + if (draft?.hasProject) { + this.actions.getProjectsComponents(draft.branchedFrom?.id ?? ''); + this.componentsLoaded.set(true); } } }); @@ -156,12 +152,13 @@ export class ReviewComponent implements OnDestroy { messageKey: 'registries.confirmDeleteDraft', onConfirm: () => { const providerId = this.draftRegistration()?.providerId; - this.actions.deleteDraft(this.draftId()).subscribe({ - next: () => { + this.actions + .deleteDraft(this.draftId()) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { this.actions.clearState(); this.router.navigateByUrl(`/registries/${providerId}/new`); - }, - }); + }); }, }); } @@ -184,11 +181,11 @@ export class ReviewComponent implements OnDestroy { components: this.components(), }, }) - .onClose.subscribe((selectedComponents) => { - if (selectedComponents) { - this.openConfirmRegistrationDialog(selectedComponents); - } - }); + .onClose.pipe( + filter((selectedComponents) => !!selectedComponents), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((selectedComponents) => this.openConfirmRegistrationDialog(selectedComponents)); } openConfirmRegistrationDialog(components?: string[]): void { @@ -206,14 +203,16 @@ export class ReviewComponent implements OnDestroy { components, }, }) - .onClose.subscribe((res) => { + .onClose.pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((res) => { if (res) { this.toastService.showSuccess('registries.review.confirmation.successMessage'); - this.router.navigate([`/${this.newRegistration()?.id}/overview`]); - } else { - if (this.components()?.length) { - this.openSelectComponentsForRegistrationDialog(); + const id = this.newRegistration()?.id; + if (id) { + this.router.navigate([`/${id}/overview`]); } + } else if (this.components()?.length) { + this.openSelectComponentsForRegistrationDialog(); } }); } diff --git a/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.html b/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.html index 334e43284..bd927b1c2 100644 --- a/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.html +++ b/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.html @@ -13,7 +13,7 @@ class="w-12rem btn-full-width" [label]="'common.buttons.back' | translate" severity="info" - (click)="dialogRef.close()" + (onClick)="dialogRef.close()" /> - +
diff --git a/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.spec.ts b/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.spec.ts index 69346c419..e698bf519 100644 --- a/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.spec.ts +++ b/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.spec.ts @@ -1,37 +1,39 @@ import { MockProvider } from 'ng-mocks'; +import { TreeNode } from 'primeng/api'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ProjectShortInfoModel } from '../../models/project-short-info.model'; + import { SelectComponentsDialogComponent } from './select-components-dialog.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideDynamicDialogRefMock } from '@testing/mocks/dynamic-dialog-ref.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; describe('SelectComponentsDialogComponent', () => { let component: SelectComponentsDialogComponent; let fixture: ComponentFixture; - let dialogRefMock: { close: jest.Mock }; - let dialogConfigMock: DynamicDialogConfig; + let dialogRef: DynamicDialogRef; - const parent = { id: 'p1', title: 'Parent Project' } as any; - const components = [ + const parent: ProjectShortInfoModel = { id: 'p1', title: 'Parent Project' }; + const components: ProjectShortInfoModel[] = [ { id: 'c1', title: 'Child 1', children: [{ id: 'c1a', title: 'Child 1A' }] }, { id: 'c2', title: 'Child 2' }, - ] as any; - - beforeEach(async () => { - dialogRefMock = { close: jest.fn() } as any; - dialogConfigMock = { data: { parent, components } } as any; + ]; - await TestBed.configureTestingModule({ - imports: [SelectComponentsDialogComponent, OSFTestingModule], + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [SelectComponentsDialogComponent], providers: [ - MockProvider(DynamicDialogRef, dialogRefMock as any), - MockProvider(DynamicDialogConfig, dialogConfigMock as any), + provideOSFCore(), + provideDynamicDialogRefMock(), + MockProvider(DynamicDialogConfig, { data: { parent, components } }), ], - }).compileComponents(); + }); + dialogRef = TestBed.inject(DynamicDialogRef); fixture = TestBed.createComponent(SelectComponentsDialogComponent); component = fixture.componentInstance; fixture.detectChanges(); @@ -43,17 +45,14 @@ describe('SelectComponentsDialogComponent', () => { const root = component.components[0]; expect(root.label).toBe('Parent Project'); expect(root.children?.length).toBe(2); - const selectedKeys = new Set(component.selectedComponents.map((n) => n.key)); - expect(selectedKeys.has('p1')).toBe(true); - expect(selectedKeys.has('c1')).toBe(true); - expect(selectedKeys.has('c1a')).toBe(true); - expect(selectedKeys.has('c2')).toBe(true); + const selectedKeys = new Set(component.selectedComponents.map((n: TreeNode) => n.key)); + expect(selectedKeys).toEqual(new Set(['p1', 'c1', 'c1a', 'c2'])); }); it('should close with unique selected component ids including parent on continue', () => { component.continue(); - expect(dialogRefMock.close).toHaveBeenCalledWith(expect.arrayContaining(['p1', 'c1', 'c1a', 'c2'])); - const passed = (dialogRefMock.close as jest.Mock).mock.calls[0][0] as string[]; + expect(dialogRef.close).toHaveBeenCalledWith(expect.arrayContaining(['p1', 'c1', 'c1a', 'c2'])); + const passed = (dialogRef.close as jest.Mock).mock.calls[0][0] as string[]; expect(new Set(passed).size).toBe(passed.length); }); }); diff --git a/src/app/features/registries/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/constants/registrations-tabs.ts b/src/app/features/registries/constants/registrations-tabs.ts index accf00f00..067163eec 100644 --- a/src/app/features/registries/constants/registrations-tabs.ts +++ b/src/app/features/registries/constants/registrations-tabs.ts @@ -1,8 +1,8 @@ -import { TabOption } from '@osf/shared/models/tab-option.model'; +import { CustomOption } from '@osf/shared/models/select-option.model'; import { RegistrationTab } from '../enums'; -export const REGISTRATIONS_TABS: TabOption[] = [ +export const REGISTRATIONS_TABS: CustomOption[] = [ { label: 'common.labels.drafts', value: RegistrationTab.Drafts, diff --git a/src/app/features/registries/enums/registration-tab.enum.ts b/src/app/features/registries/enums/registration-tab.enum.ts index 67eeac498..c7270c341 100644 --- a/src/app/features/registries/enums/registration-tab.enum.ts +++ b/src/app/features/registries/enums/registration-tab.enum.ts @@ -1,4 +1,4 @@ export enum RegistrationTab { - Drafts, - Submitted, + Drafts = 'drafts', + Submitted = 'submitted', } diff --git a/src/app/features/registries/models/attached-file.model.ts b/src/app/features/registries/models/attached-file.model.ts new file mode 100644 index 000000000..458dac9cf --- /dev/null +++ b/src/app/features/registries/models/attached-file.model.ts @@ -0,0 +1,3 @@ +import { FileModel } from '@osf/shared/models/files/file.model'; + +export type AttachedFile = Partial; diff --git a/src/app/features/registries/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/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..bd6fc8631 100644 --- a/src/app/features/registries/pages/draft-registration-custom-step/draft-registration-custom-step.component.spec.ts +++ b/src/app/features/registries/pages/draft-registration-custom-step/draft-registration-custom-step.component.spec.ts @@ -1,81 +1,108 @@ -import { MockComponent } from 'ng-mocks'; +import { Store } from '@ngxs/store'; + +import { MockComponent, 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 { RegistriesSelectors, UpdateDraft } from '@osf/features/registries/store'; +import { DraftRegistrationModel } from '@osf/shared/models/registration/draft-registration.model'; import { CustomStepComponent } from '../../components/custom-step/custom-step.component'; import { DraftRegistrationCustomStepComponent } from './draft-registration-custom-step.component'; import { MOCK_REGISTRIES_PAGE } from '@testing/mocks/registries.mock'; -import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; -describe.skip('DraftRegistrationCustomStepComponent', () => { +const MOCK_DRAFT: Partial = { + id: 'draft-1', + providerId: 'prov-1', + branchedFrom: { id: 'node-1', filesLink: '/files' }, +}; +const MOCK_STEPS_DATA: Record = { 'question-1': 'answer-1' }; + +describe('DraftRegistrationCustomStepComponent', () => { let component: DraftRegistrationCustomStepComponent; let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; - let mockRouter: ReturnType; + let store: Store; + let mockRouter: RouterMockType; - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1', step: '1' }).build(); + function setup( + draft: Partial | null = MOCK_DRAFT, + stepsData: Record = MOCK_STEPS_DATA + ) { + const mockRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1', step: '1' }).build(); mockRouter = RouterMockBuilder.create().withUrl('/registries/prov-1/draft/draft-1/custom').build(); - await TestBed.configureTestingModule({ - imports: [DraftRegistrationCustomStepComponent, OSFTestingModule, MockComponent(CustomStepComponent)], + TestBed.configureTestingModule({ + imports: [DraftRegistrationCustomStepComponent, MockComponent(CustomStepComponent)], providers: [ - { provide: ActivatedRoute, useValue: mockActivatedRoute }, - { provide: Router, useValue: mockRouter }, + provideOSFCore(), + MockProvider(ActivatedRoute, mockRoute), + MockProvider(Router, mockRouter), provideMockStore({ signals: [ - { selector: RegistriesSelectors.getStepsData, value: {} }, - { - selector: RegistriesSelectors.getDraftRegistration, - value: { id: 'draft-1', providerId: 'prov-1', branchedFrom: { id: 'node-1', filesLink: '/files' } }, - }, + { selector: RegistriesSelectors.getStepsData, value: stepsData }, + { selector: RegistriesSelectors.getDraftRegistration, value: draft }, { selector: RegistriesSelectors.getPagesSchema, value: [MOCK_REGISTRIES_PAGE] }, { selector: RegistriesSelectors.getStepsState, value: { 1: { invalid: false } } }, ], }), ], - }).compileComponents(); + }); fixture = TestBed.createComponent(DraftRegistrationCustomStepComponent); component = fixture.componentInstance; + store = TestBed.inject(Store); fixture.detectChanges(); - }); + } it('should create', () => { + setup(); expect(component).toBeTruthy(); }); it('should compute inputs from draft registration', () => { + setup(); expect(component.filesLink()).toBe('/files'); expect(component.provider()).toBe('prov-1'); expect(component.projectId()).toBe('node-1'); }); - it('should dispatch updateDraft on onUpdateAction', () => { - const actionsMock = { updateDraft: jest.fn() } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); + it('should return empty strings when draftRegistration is null', () => { + setup(null, {}); + expect(component.filesLink()).toBe(''); + expect(component.provider()).toBe(''); + expect(component.projectId()).toBe(''); + }); - component.onUpdateAction({ a: 1 } as any); - expect(actionsMock.updateDraft).toHaveBeenCalledWith('draft-1', { registration_responses: { a: 1 } }); + it('should dispatch updateDraft with wrapped registration_responses', () => { + setup(); + component.onUpdateAction({ field1: 'value1', field2: ['a', 'b'] } as any); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateDraft('draft-1', { registration_responses: { field1: 'value1', field2: ['a', 'b'] } }) + ); }); it('should navigate back to metadata on onBack', () => { - const navigateSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); + setup(); 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'); + setup(); component.onNext(); - expect(navigateSpy).toHaveBeenCalledWith(['../', 'review'], { relativeTo: TestBed.inject(ActivatedRoute) }); + expect(mockRouter.navigate).toHaveBeenCalledWith( + ['../', 'review'], + expect.objectContaining({ relativeTo: expect.anything() }) + ); }); }); diff --git a/src/app/features/registries/pages/justification/justification.component.spec.ts b/src/app/features/registries/pages/justification/justification.component.spec.ts index ddbee0e57..c69986e1e 100644 --- a/src/app/features/registries/pages/justification/justification.component.spec.ts +++ b/src/app/features/registries/pages/justification/justification.component.spec.ts @@ -1,87 +1,241 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; -import { RegistriesSelectors } from '@osf/features/registries/store'; import { StepperComponent } from '@osf/shared/components/stepper/stepper.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; +import { RevisionReviewStates } from '@osf/shared/enums/revision-review-states.enum'; +import { PageSchema } from '@osf/shared/models/registration/page-schema.model'; +import { SchemaResponse } from '@osf/shared/models/registration/schema-response.model'; import { LoaderService } from '@osf/shared/services/loader.service'; +import { ClearState, FetchSchemaBlocks, FetchSchemaResponse, RegistriesSelectors } from '../../store'; + import { JustificationComponent } from './justification.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { createMockSchemaResponse } from '@testing/mocks/schema-response.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { LoaderServiceMock } from '@testing/providers/loader-service.mock'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; +const MOCK_SCHEMA_RESPONSE = createMockSchemaResponse('resp-1', RevisionReviewStates.RevisionInProgress); + +const MOCK_PAGES: PageSchema[] = [ + { id: 'page-1', title: 'Page One', questions: [{ id: 'q1', displayText: 'Q1', required: true, responseKey: 'q1' }] }, + { id: 'page-2', title: 'Page Two', questions: [{ id: 'q2', displayText: 'Q2', required: false, responseKey: 'q2' }] }, +]; + +interface SetupOptions { + routeParams?: Record; + routerUrl?: string; + schemaResponse?: SchemaResponse | null; + pages?: PageSchema[]; + stepsState?: Record; + revisionData?: Record; +} + describe('JustificationComponent', () => { let component: JustificationComponent; let fixture: ComponentFixture; - let mockActivatedRoute: Partial; - let mockRouter: ReturnType; - - beforeEach(async () => { - mockActivatedRoute = { - snapshot: { - firstChild: { params: { id: 'rev-1', step: '0' } } as any, - } as any, - firstChild: { snapshot: { params: { id: 'rev-1', step: '0' } } } as any, - } as Partial; - mockRouter = RouterMockBuilder.create().withUrl('/registries/revisions/rev-1/justification').build(); - - await TestBed.configureTestingModule({ - imports: [JustificationComponent, OSFTestingModule, ...MockComponents(StepperComponent, SubHeaderComponent)], + let store: Store; + let mockRouter: RouterMockType; + let routerBuilder: RouterMockBuilder; + let loaderService: LoaderServiceMock; + + function setup(options: SetupOptions = {}) { + const { + routeParams = { id: 'rev-1' }, + routerUrl = '/registries/revisions/rev-1/justification', + schemaResponse = MOCK_SCHEMA_RESPONSE, + pages = MOCK_PAGES, + stepsState = {}, + revisionData = MOCK_SCHEMA_RESPONSE.revisionResponses, + } = options; + + routerBuilder = RouterMockBuilder.create().withUrl(routerUrl); + mockRouter = routerBuilder.build(); + loaderService = new LoaderServiceMock(); + + const mockRoute = ActivatedRouteMockBuilder.create() + .withFirstChild((child) => child.withParams(routeParams)) + .build(); + + TestBed.configureTestingModule({ + imports: [JustificationComponent, ...MockComponents(StepperComponent, SubHeaderComponent)], providers: [ - MockProvider(ActivatedRoute, mockActivatedRoute), + provideOSFCore(), + MockProvider(ActivatedRoute, mockRoute), MockProvider(Router, mockRouter), - MockProvider(LoaderService, { show: jest.fn(), hide: jest.fn() }), + MockProvider(LoaderService, loaderService), provideMockStore({ signals: [ - { selector: RegistriesSelectors.getPagesSchema, value: [] }, - { selector: RegistriesSelectors.getStepsState, value: { 0: { invalid: false, touched: false } } }, - { - selector: RegistriesSelectors.getSchemaResponse, - value: { - registrationSchemaId: 'schema-1', - revisionJustification: 'Reason', - reviewsState: 'revision_in_progress', - }, - }, - { selector: RegistriesSelectors.getSchemaResponseRevisionData, value: {} }, + { selector: RegistriesSelectors.getSchemaResponse, value: schemaResponse }, + { selector: RegistriesSelectors.getPagesSchema, value: pages }, + { selector: RegistriesSelectors.getStepsState, value: stepsState }, + { selector: RegistriesSelectors.getSchemaResponseRevisionData, value: revisionData }, ], }), ], - }).compileComponents(); + }); fixture = TestBed.createComponent(JustificationComponent); component = fixture.componentInstance; + store = TestBed.inject(Store); fixture.detectChanges(); - }); + } it('should create', () => { + setup(); expect(component).toBeTruthy(); }); - it('should compute steps with justification and review', () => { + it('should extract revisionId from route params', () => { + setup({ routeParams: { id: 'rev-42' } }); + expect(component.revisionId).toBe('rev-42'); + }); + + it('should default revisionId to empty string when no id param', () => { + setup({ routeParams: {} }); + expect(component.revisionId).toBe(''); + }); + + it('should build justification as first and review as last step with custom steps in between', () => { + setup(); + const steps = component.steps(); + expect(steps.length).toBe(4); + expect(steps[0]).toEqual(expect.objectContaining({ index: 0, value: 'justification', routeLink: 'justification' })); + expect(steps[1]).toEqual(expect.objectContaining({ index: 1, label: 'Page One', value: 'page-1', routeLink: '1' })); + expect(steps[2]).toEqual(expect.objectContaining({ index: 2, label: 'Page Two', value: 'page-2', routeLink: '2' })); + expect(steps[3]).toEqual( + expect.objectContaining({ index: 3, value: 'review', routeLink: 'review', invalid: false }) + ); + }); + + it('should mark justification step as invalid when revisionJustification is empty', () => { + setup({ schemaResponse: { ...MOCK_SCHEMA_RESPONSE, revisionJustification: '' } }); + const step = component.steps()[0]; + expect(step.invalid).toBe(true); + expect(step.touched).toBe(false); + }); + + it('should disable steps when reviewsState is not RevisionInProgress', () => { + setup({ schemaResponse: createMockSchemaResponse('resp-1', RevisionReviewStates.Approved) }); + const steps = component.steps(); + expect(steps[0].disabled).toBe(true); + expect(steps[1].disabled).toBe(true); + }); + + it('should apply stepsState invalid/touched to custom steps', () => { + setup({ stepsState: { 1: { invalid: true, touched: true }, 2: { invalid: false, touched: false } } }); + const steps = component.steps(); + expect(steps[1]).toEqual(expect.objectContaining({ invalid: true, touched: true })); + expect(steps[2]).toEqual(expect.objectContaining({ invalid: false, touched: false })); + }); + + it('should handle null schemaResponse gracefully', () => { + setup({ schemaResponse: null }); + const step = component.steps()[0]; + expect(step.invalid).toBe(true); + expect(step.disabled).toBe(true); + }); + + it('should produce only justification and review when no pages', () => { + setup({ pages: [] }); const steps = component.steps(); expect(steps.length).toBe(2); expect(steps[0].value).toBe('justification'); - expect(steps[1].value).toBe('review'); + expect(steps[1]).toEqual(expect.objectContaining({ index: 1, value: 'review' })); + }); + + it('should initialize currentStepIndex from route step param', () => { + setup({ routeParams: { id: 'rev-1', step: '2' } }); + expect(component.currentStepIndex()).toBe(2); + }); + + it('should default currentStepIndex to 0 when no step param', () => { + setup(); + expect(component.currentStepIndex()).toBe(0); + }); + + it('should return the step at currentStepIndex', () => { + setup(); + component.currentStepIndex.set(0); + expect(component.currentStep().value).toBe('justification'); + }); + + it('should update currentStepIndex and navigate on stepChange', () => { + setup(); + component.stepChange({ index: 1, label: 'Page One', value: 'page-1' } as any); + expect(component.currentStepIndex()).toBe(1); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/registries/revisions/rev-1/', '1']); + }); + + it('should navigate to review route for last step', () => { + setup(); + const reviewIndex = component.steps().length - 1; + component.stepChange({ index: reviewIndex, label: 'Review', value: 'review' } as any); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/registries/revisions/rev-1/', 'review']); + }); + + it('should update currentStepIndex on NavigationEnd', () => { + setup({ routeParams: { id: 'rev-1', step: '2' }, routerUrl: '/registries/revisions/rev-1/2' }); + routerBuilder.emit(new NavigationEnd(1, '/test', '/test')); + expect(component.currentStepIndex()).toBe(2); + }); + + it('should show loader on init', () => { + setup(); + expect(loaderService.show).toHaveBeenCalled(); + }); + + it('should dispatch FetchSchemaResponse when not already loaded', () => { + setup({ schemaResponse: null }); + expect(store.dispatch).toHaveBeenCalledWith(new FetchSchemaResponse('rev-1')); + }); + + it('should not dispatch FetchSchemaResponse when already loaded', () => { + setup(); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(FetchSchemaResponse)); + }); + + it('should dispatch FetchSchemaBlocks when schemaResponse has registrationSchemaId', () => { + setup(); + expect(store.dispatch).toHaveBeenCalledWith(new FetchSchemaBlocks(MOCK_SCHEMA_RESPONSE.registrationSchemaId)); + }); + + it('should dispatch clearState on destroy', () => { + setup(); + (store.dispatch as jest.Mock).mockClear(); + component.ngOnDestroy(); + expect(store.dispatch).toHaveBeenCalledWith(new ClearState()); + }); + + it('should detect review page from URL', () => { + setup({ routerUrl: '/registries/revisions/rev-1/review' }); + expect(component['isReviewPage']).toBe(true); + }); + + it('should return false for isReviewPage when not on review', () => { + setup(); + expect(component['isReviewPage']).toBe(false); }); - it('should navigate on stepChange', () => { - const navSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); - component.stepChange({ index: 1, routeLink: '1', value: 'p1', label: 'Page 1' } as any); - expect(navSpy).toHaveBeenCalledWith(['/registries/revisions/rev-1/', 'review']); + it('should set currentStepIndex to last step on NavigationEnd when on review page without step param', () => { + setup({ routeParams: { id: 'rev-1' }, routerUrl: '/registries/revisions/rev-1/review' }); + component.currentStepIndex.set(0); + routerBuilder.emit(new NavigationEnd(2, '/review', '/review')); + expect(component.currentStepIndex()).toBe(MOCK_PAGES.length + 1); }); - it('should clear state on destroy', () => { - const actionsMock = { - clearState: jest.fn(), - getSchemaBlocks: jest.fn().mockReturnValue({ pipe: () => ({ subscribe: () => {} }) }), - } as any; - Object.defineProperty(component as any, 'actions', { value: actionsMock }); - fixture.destroy(); - expect(actionsMock.clearState).toHaveBeenCalled(); + it('should reset currentStepIndex to 0 on NavigationEnd when not on review and no step param', () => { + setup({ routeParams: { id: 'rev-1' }, routerUrl: '/registries/revisions/rev-1/justification' }); + component.currentStepIndex.set(2); + routerBuilder.emit(new NavigationEnd(2, '/justification', '/justification')); + expect(component.currentStepIndex()).toBe(0); }); }); diff --git a/src/app/features/registries/pages/justification/justification.component.ts b/src/app/features/registries/pages/justification/justification.component.ts index e196e9038..610dfd668 100644 --- a/src/app/features/registries/pages/justification/justification.component.ts +++ b/src/app/features/registries/pages/justification/justification.component.ts @@ -2,7 +2,7 @@ import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { filter, tap } from 'rxjs'; +import { filter } from 'rxjs'; import { ChangeDetectionStrategy, @@ -12,7 +12,6 @@ import { effect, inject, OnDestroy, - Signal, signal, untracked, } from '@angular/core'; @@ -33,21 +32,14 @@ import { ClearState, FetchSchemaBlocks, FetchSchemaResponse, RegistriesSelectors templateUrl: './justification.component.html', styleUrl: './justification.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [TranslateService], }) export class JustificationComponent implements OnDestroy { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); - private readonly loaderService = inject(LoaderService); private readonly translateService = inject(TranslateService); - readonly pages = select(RegistriesSelectors.getPagesSchema); - readonly stepsState = select(RegistriesSelectors.getStepsState); - readonly schemaResponse = select(RegistriesSelectors.getSchemaResponse); - readonly schemaResponseRevisionData = select(RegistriesSelectors.getSchemaResponseRevisionData); - private readonly actions = createDispatchMap({ getSchemaBlocks: FetchSchemaBlocks, clearState: ClearState, @@ -55,61 +47,79 @@ export class JustificationComponent implements OnDestroy { updateStepState: UpdateStepState, }); + readonly pages = select(RegistriesSelectors.getPagesSchema); + readonly stepsState = select(RegistriesSelectors.getStepsState); + readonly schemaResponse = select(RegistriesSelectors.getSchemaResponse); + readonly schemaResponseRevisionData = select(RegistriesSelectors.getSchemaResponseRevisionData); + + readonly revisionId = this.route.snapshot.firstChild?.params['id'] || ''; + get isReviewPage(): boolean { return this.router.url.includes('/review'); } - reviewStep!: StepOption; - justificationStep!: StepOption; - revisionId = this.route.snapshot.firstChild?.params['id'] || ''; + readonly steps = computed(() => { + const response = this.schemaResponse(); + const isJustificationValid = !!response?.revisionJustification; + const isDisabled = response?.reviewsState !== RevisionReviewStates.RevisionInProgress; + const stepState = this.stepsState(); + const pages = this.pages(); - steps: Signal = computed(() => { - const isJustificationValid = !!this.schemaResponse()?.revisionJustification; - this.justificationStep = { + const justificationStep: StepOption = { index: 0, value: 'justification', label: this.translateService.instant('registries.justification.step'), invalid: !isJustificationValid, touched: isJustificationValid, routeLink: 'justification', - disabled: this.schemaResponse()?.reviewsState !== RevisionReviewStates.RevisionInProgress, + disabled: isDisabled, }; - this.reviewStep = { - index: 1, + const customSteps: StepOption[] = pages.map((page, index) => ({ + index: index + 1, + label: page.title, + value: page.id, + routeLink: `${index + 1}`, + invalid: stepState?.[index + 1]?.invalid || false, + touched: stepState?.[index + 1]?.touched || false, + disabled: isDisabled, + })); + + const reviewStep: StepOption = { + index: customSteps.length + 1, value: 'review', label: this.translateService.instant('registries.review.step'), invalid: false, routeLink: 'review', }; - const stepState = this.stepsState(); - const customSteps = this.pages().map((page, index) => { - return { - index: index + 1, - label: page.title, - value: page.id, - routeLink: `${index + 1}`, - invalid: stepState?.[index + 1]?.invalid || false, - touched: stepState?.[index + 1]?.touched || false, - disabled: this.schemaResponse()?.reviewsState !== RevisionReviewStates.RevisionInProgress, - }; - }); - return [ - { ...this.justificationStep }, - ...customSteps, - { ...this.reviewStep, index: customSteps.length + 1, invalid: false }, - ]; + + return [justificationStep, ...customSteps, reviewStep]; }); currentStepIndex = signal( this.route.snapshot.firstChild?.params['step'] ? +this.route.snapshot.firstChild?.params['step'] : 0 ); - currentStep = computed(() => { - return this.steps()[this.currentStepIndex()]; - }); + currentStep = computed(() => this.steps()[this.currentStepIndex()]); constructor() { + this.initRouterListener(); + this.initDataFetching(); + this.initReviewPageSync(); + this.initStepValidation(); + } + + ngOnDestroy(): void { + this.actions.clearState(); + } + + stepChange(step: StepOption): void { + this.currentStepIndex.set(step.index); + const pageLink = this.steps()[step.index].routeLink; + this.router.navigate([`/registries/revisions/${this.revisionId}/`, pageLink]); + } + + private initRouterListener(): void { this.router.events .pipe( takeUntilDestroyed(this.destroyRef), @@ -120,47 +130,56 @@ export class JustificationComponent implements OnDestroy { if (step) { this.currentStepIndex.set(+step); } else if (this.isReviewPage) { - const reviewStepIndex = this.pages().length + 1; - this.currentStepIndex.set(reviewStepIndex); + this.currentStepIndex.set(this.pages().length + 1); } else { this.currentStepIndex.set(0); } }); + } + private initDataFetching(): void { this.loaderService.show(); + if (!this.schemaResponse()) { this.actions.getSchemaResponse(this.revisionId); } effect(() => { const registrationSchemaId = this.schemaResponse()?.registrationSchemaId; + if (registrationSchemaId) { - this.actions - .getSchemaBlocks(registrationSchemaId) - .pipe(tap(() => this.loaderService.hide())) - .subscribe(); + this.actions.getSchemaBlocks(registrationSchemaId).subscribe(() => this.loaderService.hide()); } }); + } + private initReviewPageSync(): void { effect(() => { const reviewStepIndex = this.pages().length + 1; + if (this.isReviewPage) { this.currentStepIndex.set(reviewStepIndex); } }); + } + private initStepValidation(): void { effect(() => { + const currentIndex = this.currentStepIndex(); + const pages = this.pages(); + const revisionData = this.schemaResponseRevisionData(); const stepState = untracked(() => this.stepsState()); - if (this.currentStepIndex() > 0) { + if (currentIndex > 0) { this.actions.updateStepState('0', true, stepState?.[0]?.touched || false); } - if (this.pages().length && this.currentStepIndex() > 0 && this.schemaResponseRevisionData()) { - for (let i = 1; i < this.currentStepIndex(); i++) { - const pageStep = this.pages()[i - 1]; + + if (pages.length && currentIndex > 0 && revisionData) { + for (let i = 1; i < currentIndex; i++) { + const pageStep = pages[i - 1]; const isStepInvalid = pageStep?.questions?.some((question) => { - const questionData = this.schemaResponseRevisionData()[question.responseKey!]; + const questionData = revisionData[question.responseKey!]; return question.required && (Array.isArray(questionData) ? !questionData.length : !questionData); }) || false; this.actions.updateStepState(i.toString(), isStepInvalid, stepState?.[i]?.touched || false); @@ -168,14 +187,4 @@ export class JustificationComponent implements OnDestroy { } }); } - - stepChange(step: StepOption): void { - this.currentStepIndex.set(step.index); - const pageLink = this.steps()[step.index].routeLink; - this.router.navigate([`/registries/revisions/${this.revisionId}/`, pageLink]); - } - - ngOnDestroy(): void { - this.actions.clearState(); - } } diff --git a/src/app/features/registries/pages/my-registrations-redirect/my-registrations-redirect.component.spec.ts b/src/app/features/registries/pages/my-registrations-redirect/my-registrations-redirect.component.spec.ts index d4f40e53f..b82eba951 100644 --- a/src/app/features/registries/pages/my-registrations-redirect/my-registrations-redirect.component.spec.ts +++ b/src/app/features/registries/pages/my-registrations-redirect/my-registrations-redirect.component.spec.ts @@ -28,24 +28,10 @@ describe('MyRegistrationsRedirectComponent', () => { expect(component).toBeTruthy(); }); - it('should be an instance of MyRegistrationsRedirectComponent', () => { - expect(component).toBeInstanceOf(MyRegistrationsRedirectComponent); - }); - it('should navigate to /my-registrations on component creation', () => { expect(router.navigate).toHaveBeenCalledWith(['/my-registrations'], { queryParamsHandling: 'preserve', replaceUrl: true, }); }); - - it('should preserve query parameters during navigation', () => { - const navigationOptions = router.navigate.mock.calls[0][1]; - expect(navigationOptions?.queryParamsHandling).toBe('preserve'); - }); - - it('should replace the current URL in browser history', () => { - const navigationOptions = router.navigate.mock.calls[0][1]; - expect(navigationOptions?.replaceUrl).toBe(true); - }); }); diff --git a/src/app/features/registries/pages/my-registrations/my-registrations.component.html b/src/app/features/registries/pages/my-registrations/my-registrations.component.html index d9197ccaa..45a6b4e5f 100644 --- a/src/app/features/registries/pages/my-registrations/my-registrations.component.html +++ b/src/app/features/registries/pages/my-registrations/my-registrations.component.html @@ -10,7 +10,7 @@
- + @if (!isMobile()) { @for (tab of tabOptions; track tab.value) { diff --git a/src/app/features/registries/pages/my-registrations/my-registrations.component.spec.ts b/src/app/features/registries/pages/my-registrations/my-registrations.component.spec.ts index 5f1c0f4e4..1d2557ed9 100644 --- a/src/app/features/registries/pages/my-registrations/my-registrations.component.spec.ts +++ b/src/app/features/registries/pages/my-registrations/my-registrations.component.spec.ts @@ -1,6 +1,6 @@ -import { MockComponents, MockProvider } from 'ng-mocks'; +import { Store } from '@ngxs/store'; -import { of } from 'rxjs'; +import { MockComponents, MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; @@ -15,35 +15,41 @@ 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 { provideOSFCore } from '@testing/osf.testing.provider'; +import { CustomConfirmationServiceMock } from '@testing/providers/custom-confirmation-provider.mock'; +import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { ToastServiceMock } from '@testing/providers/toast-provider.mock'; describe('MyRegistrationsComponent', () => { let component: MyRegistrationsComponent; 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(), + MockProvider(Router, mockRouter), + MockProvider(ActivatedRoute, mockRoute), + MockProvider(CustomConfirmationService, CustomConfirmationServiceMock.simple()), + MockProvider(ToastService, ToastServiceMock.simple()), provideMockStore({ signals: [ { selector: RegistriesSelectors.getDraftRegistrations, value: [] }, @@ -56,130 +62,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 +176,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..8d90557a1 100644 --- a/src/app/features/registries/pages/registries-landing/registries-landing.component.spec.ts +++ b/src/app/features/registries/pages/registries-landing/registries-landing.component.spec.ts @@ -1,36 +1,40 @@ -import { MockComponents } from 'ng-mocks'; +import { Store } from '@ngxs/store'; +import { MockComponents, MockProvider } from 'ng-mocks'; + +import { PLATFORM_ID } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; import { 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 { 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 +45,21 @@ describe('RegistriesLandingComponent', () => { ), ], providers: [ - { provide: Router, useValue: mockRouter }, + provideOSFCore(), + MockProvider(Router, 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 +67,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..ea8d99376 100644 --- a/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts +++ b/src/app/features/registries/pages/registries-provider-search/registries-provider-search.component.spec.ts @@ -1,67 +1,116 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; +import { PLATFORM_ID } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { 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 { provideOSFCore } from '@testing/osf.testing.provider'; import { ActivatedRouteMockBuilder } 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(), + MockProvider(ActivatedRoute, 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..6aa227fc6 100644 --- a/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.spec.ts +++ b/src/app/features/registries/pages/revisions-custom-step/revisions-custom-step.component.spec.ts @@ -1,32 +1,35 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; import { 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 { provideOSFCore } from '@testing/osf.testing.provider'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('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), + provideOSFCore(), + MockProvider(ActivatedRoute, mockRoute), MockProvider(Router, mockRouter), provideMockStore({ signals: [ @@ -43,10 +46,11 @@ describe('RevisionsCustomStepComponent', () => { ], }), ], - }).compileComponents(); + }); fixture = TestBed.createComponent(RevisionsCustomStepComponent); component = fixture.componentInstance; + store = TestBed.inject(Store); fixture.detectChanges(); }); @@ -62,21 +66,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/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)) ); } 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/app/features/registry/pages/registry-overview/registry-overview.component.ts b/src/app/features/registry/pages/registry-overview/registry-overview.component.ts index 69466b6e5..03978f4f0 100644 --- a/src/app/features/registry/pages/registry-overview/registry-overview.component.ts +++ b/src/app/features/registry/pages/registry-overview/registry-overview.component.ts @@ -20,7 +20,6 @@ import { import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; -import { CreateSchemaResponse } from '@osf/features/registries/store'; import { DataResourcesComponent } from '@osf/shared/components/data-resources/data-resources.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; @@ -46,6 +45,7 @@ import { RegistryRevisionsComponent } from '../../components/registry-revisions/ import { RegistryStatusesComponent } from '../../components/registry-statuses/registry-statuses.component'; import { WithdrawnMessageComponent } from '../../components/withdrawn-message/withdrawn-message.component'; import { + CreateSchemaResponse, GetRegistryById, GetRegistryReviewActions, GetRegistrySchemaResponses, 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.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.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/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/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.spec.ts b/src/app/shared/components/add-project-form/add-project-form.component.spec.ts index 54336feae..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,7 +9,7 @@ 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 { 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'; 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.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..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'; @@ -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( 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/contributors/remove-contributor-dialog/remove-contributor-dialog.component.html b/src/app/shared/components/contributors/remove-contributor-dialog/remove-contributor-dialog.component.html index 6b579178e..520de170a 100644 --- a/src/app/shared/components/contributors/remove-contributor-dialog/remove-contributor-dialog.component.html +++ b/src/app/shared/components/contributors/remove-contributor-dialog/remove-contributor-dialog.component.html @@ -1,5 +1,5 @@
-

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

+

diff --git a/src/app/shared/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/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/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/funder-awards-list/funder-awards-list.component.html b/src/app/shared/funder-awards-list/funder-awards-list.component.html new file mode 100644 index 000000000..95fcdad0b --- /dev/null +++ b/src/app/shared/funder-awards-list/funder-awards-list.component.html @@ -0,0 +1,24 @@ +
+ @if (isLoading()) { +
+ +
+ } @else { + @if (funders().length) { +

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

+
+ @for (funder of funders(); track $index) { + +
{{ funder.funderName }}
+ +
{{ funder.awardNumber }}
+
+ } +
+ } + } +
diff --git a/src/app/shared/funder-awards-list/funder-awards-list.component.scss b/src/app/shared/funder-awards-list/funder-awards-list.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/shared/funder-awards-list/funder-awards-list.component.spec.ts b/src/app/shared/funder-awards-list/funder-awards-list.component.spec.ts new file mode 100644 index 000000000..d066f8152 --- /dev/null +++ b/src/app/shared/funder-awards-list/funder-awards-list.component.spec.ts @@ -0,0 +1,66 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { provideRouter } from '@angular/router'; + +import { FunderAwardsListComponent } from './funder-awards-list.component'; + +import { MOCK_FUNDERS } from '@testing/mocks/funder.mock'; +import { OSFTestingModule } from '@testing/osf.testing.module'; + +describe('FunderAwardsListComponent', () => { + let component: FunderAwardsListComponent; + let fixture: ComponentFixture; + + const MOCK_REGISTRY_ID = 'test-registry-123'; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [FunderAwardsListComponent, OSFTestingModule], + providers: [provideRouter([])], + }).compileComponents(); + + fixture = TestBed.createComponent(FunderAwardsListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should not render the list or label if funders array is empty', () => { + fixture.componentRef.setInput('funders', []); + fixture.detectChanges(); + const label = fixture.debugElement.query(By.css('p')); + const links = fixture.debugElement.queryAll(By.css('a')); + expect(label).toBeNull(); + expect(links.length).toBe(0); + }); + + it('should render a list of funders when data is provided', () => { + fixture.componentRef.setInput('funders', MOCK_FUNDERS); + fixture.componentRef.setInput('registryId', MOCK_REGISTRY_ID); + fixture.detectChanges(); + const links = fixture.debugElement.queryAll(By.css('a')); + expect(links.length).toBe(2); + const firstItemText = links[0].nativeElement.textContent; + expect(firstItemText).toContain('National Science Foundation'); + expect(firstItemText).toContain('NSF-1234567'); + }); + + it('should generate the correct router link', () => { + fixture.componentRef.setInput('funders', MOCK_FUNDERS); + fixture.componentRef.setInput('registryId', MOCK_REGISTRY_ID); + fixture.detectChanges(); + const linkDebugEl = fixture.debugElement.query(By.css('a')); + const href = linkDebugEl.nativeElement.getAttribute('href'); + expect(href).toContain(`/${MOCK_REGISTRY_ID}/metadata/osf`); + }); + + it('should open links in a new tab', () => { + fixture.componentRef.setInput('funders', MOCK_FUNDERS); + fixture.detectChanges(); + const linkDebugEl = fixture.debugElement.query(By.css('a')); + expect(linkDebugEl.attributes['target']).toBe('_blank'); + }); +}); diff --git a/src/app/shared/funder-awards-list/funder-awards-list.component.ts b/src/app/shared/funder-awards-list/funder-awards-list.component.ts new file mode 100644 index 000000000..969bf1053 --- /dev/null +++ b/src/app/shared/funder-awards-list/funder-awards-list.component.ts @@ -0,0 +1,21 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Skeleton } from 'primeng/skeleton'; + +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { RouterLink } from '@angular/router'; + +import { Funder } from '@osf/features/metadata/models'; + +@Component({ + selector: 'osf-funder-awards-list', + imports: [RouterLink, TranslatePipe, Skeleton], + templateUrl: './funder-awards-list.component.html', + styleUrl: './funder-awards-list.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FunderAwardsListComponent { + funders = input([]); + registryId = input(null); + isLoading = input(false); +} diff --git a/src/app/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..680b34a04 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'; @@ -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/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/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/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 96% rename from src/app/shared/models/collections/collections.models.ts rename to src/app/shared/models/collections/collections.model.ts index c130c21f1..6b67d7d16 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 { @@ -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/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/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; } diff --git a/src/app/shared/models/registration/draft-registration.model.ts b/src/app/shared/models/registration/draft-registration.model.ts index 4d0230e0d..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; @@ -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; 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..266ba23f2 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'; @@ -248,11 +248,9 @@ export class ContributorsService { userId: string, removeFromChildren = false ): Observable { - let baseUrl = `${this.getBaseUrl(resourceType, resourceId)}/${userId}/`; - if (removeFromChildren) { - baseUrl = baseUrl.concat('?propagate_to_children=true'); - } + const baseUrl = `${this.getBaseUrl(resourceType, resourceId)}/${userId}/`; + const url = removeFromChildren ? `${baseUrl}?include_children=true` : baseUrl; - return this.jsonApiService.delete(baseUrl); + return this.jsonApiService.delete(url); } } 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 9858f02f2..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'; @@ -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/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/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 7645e7d6b..85b19d02e 100644 --- a/src/app/shared/stores/institutions/institutions.actions.ts +++ b/src/app/shared/stores/institutions/institutions.actions.ts @@ -1,8 +1,9 @@ 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'; + constructor(public userId = 'me') {} } export class FetchInstitutions { 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/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({ 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/assets/i18n/en.json b/src/assets/i18n/en.json index 9ee6ffb5c..b6f5f392a 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", @@ -1179,7 +1180,7 @@ "title": "Select Destination", "dialogTitle": "Move file", "dialogTitleMultiple": "Move files", - "message": "Are you sure you want to move {{dragNodeName}} to {{dropNodeName}} ?", + "message": "Are you sure you want to move {{dragNodeName}} to {{dropNodeName}}?", "multipleFiles": "{{count}} files", "storage": "OSF Storage", "pathError": "Path is not specified!", @@ -2645,6 +2646,7 @@ }, "metadata": { "type": "Registration Type", + "registry": "Registry", "registeredDate": "Date registered", "doi": "Registration DOI", "associatedProject": "Associated project" @@ -2779,6 +2781,7 @@ "withdrawn": "Withdrawn", "from": "From:", "funder": "Funder:", + "funderAwards": "Funder awards:", "resourceNature": "Resource type:", "dateCreated": "Date created", "dateModified": "Date modified", diff --git a/src/server.ts b/src/server.ts index 1a7ffc292..272569de9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,12 +6,25 @@ import { } from '@angular/ssr/node'; import express from 'express'; +import { existsSync, readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const serverDistFolder = dirname(fileURLToPath(import.meta.url)); const browserDistFolder = resolve(serverDistFolder, '../browser'); +const configPath = resolve(browserDistFolder, 'assets/config/config.json'); + +if (existsSync(configPath)) { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).__SSR_CONFIG__ = JSON.parse(readFileSync(configPath, 'utf-8')); + } catch { + // eslint-disable-next-line no-console + console.warn('Failed to parse SSR config at', configPath); + } +} + const app = express(); const angularApp = new AngularNodeAppEngine(); 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..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.models'; +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', +}; diff --git a/src/testing/mocks/data.mock.ts b/src/testing/mocks/data.mock.ts index 6acecbf35..f8503e40b 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 = { @@ -58,6 +58,7 @@ export const MOCK_USER: UserModel = { allowIndexing: true, canViewReviews: true, mergedBy: undefined, + external_identity: {}, }; export const MOCK_USER_RELATED_COUNTS: UserRelatedCounts = { diff --git a/src/testing/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/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/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/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/osf.testing.provider.ts b/src/testing/osf.testing.provider.ts new file mode 100644 index 000000000..5667c36a0 --- /dev/null +++ b/src/testing/osf.testing.provider.ts @@ -0,0 +1,22 @@ +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 { EnvironmentTokenMock } from './mocks/environment.token.mock'; +import { TranslationServiceMock } from './mocks/translation.service.mock'; + +export function provideOSFCore() { + return [ + provideNoopAnimations(), + importProvidersFrom(TranslateModule.forRoot()), + TranslationServiceMock, + EnvironmentTokenMock, + ]; +} + +export function provideOSFHttp() { + return [provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting()]; +} 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 { diff --git a/src/testing/providers/component-provider.mock.ts b/src/testing/providers/component-provider.mock.ts index 92f036bf4..a63fe60ad 100644 --- a/src/testing/providers/component-provider.mock.ts +++ b/src/testing/providers/component-provider.mock.ts @@ -39,7 +39,6 @@ import { Component, EventEmitter, Input } from '@angular/core'; export function MockComponentWithSignal(selector: string, inputs: string[] = [], outputs: string[] = []): Type { @Component({ selector, - standalone: true, template: '', }) class MockComponent { 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(), + }; +}