diff --git a/src/app/features/registries/components/new-registration/new-registration.component.html b/src/app/features/registries/components/new-registration/new-registration.component.html index 7c46cb5e9..2037844a7 100644 --- a/src/app/features/registries/components/new-registration/new-registration.component.html +++ b/src/app/features/registries/components/new-registration/new-registration.component.html @@ -1,86 +1,92 @@ -
- +@if (canShowForm()) { +
+ -
-

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

-
+
+

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

+
+ +
+ +

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

+

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

+
+ + +
+
-
- -

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

-

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

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

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

+

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

+

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

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

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

-

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

-

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

+

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

+

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

- } - -

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

-

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

-
- +
-
- -
- -
-
+ +
-
+} @else { +
+ +
+} diff --git a/src/app/features/registries/components/new-registration/new-registration.component.spec.ts b/src/app/features/registries/components/new-registration/new-registration.component.spec.ts index c06634a3a..80246925c 100644 --- a/src/app/features/registries/components/new-registration/new-registration.component.spec.ts +++ b/src/app/features/registries/components/new-registration/new-registration.component.spec.ts @@ -9,7 +9,7 @@ import { UserSelectors } from '@core/store/user'; import { CreateDraft, GetProjects, GetProviderSchemas, RegistriesSelectors } from '@osf/features/registries/store'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; import { ToastService } from '@osf/shared/services/toast.service'; -import { GetRegistryProvider } from '@shared/stores/registration-provider'; +import { GetRegistryProvider, RegistrationProviderSelectors } from '@shared/stores/registration-provider'; import { NewRegistrationComponent } from './new-registration.component'; @@ -17,38 +17,53 @@ import { MOCK_PROVIDER_SCHEMAS } from '@testing/mocks/registries.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { + BaseSetupOverrides, + mergeSignalOverrides, + provideMockStore, + SignalOverride, +} from '@testing/providers/store-provider.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; describe('NewRegistrationComponent', () => { let component: NewRegistrationComponent; let fixture: ComponentFixture; let store: Store; let mockRouter: RouterMockType; - - beforeEach(() => { + let toastService: ToastServiceMockType; + + interface SetupOverrides extends BaseSetupOverrides { + selectorOverrides?: SignalOverride[]; + } + + const defaultSignals: SignalOverride[] = [ + { selector: RegistriesSelectors.getProjects, value: [{ id: 'p1', title: 'P1' }] }, + { selector: RegistriesSelectors.getProviderSchemas, value: MOCK_PROVIDER_SCHEMAS }, + { selector: RegistriesSelectors.isDraftSubmitting, value: false }, + { selector: RegistriesSelectors.getDraftRegistration, value: { id: 'draft-1' } }, + { selector: RegistriesSelectors.isProvidersLoading, value: false }, + { selector: RegistriesSelectors.isProjectsLoading, value: false }, + { selector: UserSelectors.getCurrentUser, value: { id: 'user-1' } }, + { selector: RegistrationProviderSelectors.getBrandedProvider, value: { id: 'prov-1', allowSubmissions: true } }, + ]; + + const setup = (overrides?: SetupOverrides) => { const mockActivatedRoute = ActivatedRouteMockBuilder.create() - .withParams({ providerId: 'prov-1' }) + .withParams(overrides?.routeParams || { providerId: 'prov-1' }) .withQueryParams({ projectId: 'proj-1' }) .build(); mockRouter = RouterMockBuilder.create().withUrl('/x').build(); + toastService = ToastServiceMock.simple(); TestBed.configureTestingModule({ imports: [NewRegistrationComponent, MockComponent(SubHeaderComponent)], providers: [ provideOSFCore(), MockProvider(ActivatedRoute, mockActivatedRoute), - MockProvider(ToastService), + MockProvider(ToastService, toastService), MockProvider(Router, mockRouter), provideMockStore({ - signals: [ - { selector: RegistriesSelectors.getProjects, value: [{ id: 'p1', title: 'P1' }] }, - { selector: RegistriesSelectors.getProviderSchemas, value: MOCK_PROVIDER_SCHEMAS }, - { selector: RegistriesSelectors.isDraftSubmitting, value: false }, - { selector: RegistriesSelectors.getDraftRegistration, value: { id: 'draft-1' } }, - { selector: RegistriesSelectors.isProvidersLoading, value: false }, - { selector: RegistriesSelectors.isProjectsLoading, value: false }, - { selector: UserSelectors.getCurrentUser, value: { id: 'user-1' } }, - ], + signals: mergeSignalOverrides(defaultSignals, overrides?.selectorOverrides), }), ], }); @@ -57,31 +72,69 @@ describe('NewRegistrationComponent', () => { fixture = TestBed.createComponent(NewRegistrationComponent); component = fixture.componentInstance; fixture.detectChanges(); - }); + }; it('should create', () => { + setup(); expect(component).toBeTruthy(); }); + it('should allow submissions when provider allows it', () => { + setup(); + expect(component.canShowForm()).toBe(true); + expect(toastService.showError).not.toHaveBeenCalled(); + expect(mockRouter.navigate).not.toHaveBeenCalled(); + }); + + it('should redirect and show error when submissions are not allowed', () => { + setup({ + selectorOverrides: [ + { + selector: RegistrationProviderSelectors.getBrandedProvider, + value: { id: 'prov-1', allowSubmissions: false }, + }, + ], + }); + + expect(component.canShowForm()).toBe(false); + expect(toastService.showError).toHaveBeenCalledWith('registries.new.registryClosedForSubmissions'); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/registries', 'prov-1']); + }); + + it('should redirect and show error when allowSubmissions is undefined', () => { + setup({ + selectorOverrides: [{ selector: RegistrationProviderSelectors.getBrandedProvider, value: { id: 'prov-1' } }], + }); + + expect(component.canShowForm()).toBe(false); + expect(toastService.showError).toHaveBeenCalledWith('registries.new.registryClosedForSubmissions'); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/registries', 'prov-1']); + }); + it('should dispatch initial data fetching on init', () => { + setup(); expect(store.dispatch).toHaveBeenCalledWith(new GetProjects('user-1', '')); expect(store.dispatch).toHaveBeenCalledWith(new GetRegistryProvider('prov-1')); expect(store.dispatch).toHaveBeenCalledWith(new GetProviderSchemas('prov-1')); }); it('should init fromProject as true when projectId is present', () => { + setup(); expect(component.fromProject()).toBe(true); }); it('should init form with project id from route', () => { + setup(); expect(component.draftForm.get('project')?.value).toBe('proj-1'); }); it('should default providerSchema when schemas are available', () => { + setup(); expect(component.draftForm.get('providerSchema')?.value).toBe('schema-1'); }); it('should toggle fromProject and add/remove validator', () => { + setup(); component.fromProject.set(false); component.toggleFromProject(); expect(component.fromProject()).toBe(true); @@ -93,6 +146,7 @@ describe('NewRegistrationComponent', () => { }); it('should dispatch createDraft and navigate when form is valid', () => { + setup(); component.draftForm.patchValue({ providerSchema: 'schema-1', project: 'proj-1' }); component.fromProject.set(true); (store.dispatch as jest.Mock).mockClear(); @@ -106,6 +160,7 @@ describe('NewRegistrationComponent', () => { }); it('should not dispatch createDraft when form is invalid', () => { + setup(); component.draftForm.patchValue({ providerSchema: '' }); (store.dispatch as jest.Mock).mockClear(); @@ -115,6 +170,7 @@ describe('NewRegistrationComponent', () => { }); it('should dispatch getProjects after debounced filter', fakeAsync(() => { + setup(); (store.dispatch as jest.Mock).mockClear(); component.onProjectFilter('abc'); @@ -124,6 +180,7 @@ describe('NewRegistrationComponent', () => { })); it('should not dispatch duplicate getProjects for same filter value', fakeAsync(() => { + setup(); (store.dispatch as jest.Mock).mockClear(); component.onProjectFilter('abc'); @@ -132,12 +189,13 @@ describe('NewRegistrationComponent', () => { tick(300); const getProjectsCalls = (store.dispatch as jest.Mock).mock.calls.filter( - ([action]: [any]) => action instanceof GetProjects + ([action]: [unknown]) => action instanceof GetProjects ); expect(getProjectsCalls.length).toBe(1); })); it('should debounce rapid filter calls and dispatch only the last value', fakeAsync(() => { + setup(); (store.dispatch as jest.Mock).mockClear(); component.onProjectFilter('a'); @@ -146,7 +204,7 @@ describe('NewRegistrationComponent', () => { tick(300); const getProjectsCalls = (store.dispatch as jest.Mock).mock.calls.filter( - ([action]: [any]) => action instanceof GetProjects + ([action]: [unknown]) => action instanceof GetProjects ); expect(getProjectsCalls.length).toBe(1); expect(getProjectsCalls[0][0]).toEqual(new GetProjects('user-1', 'abc')); diff --git a/src/app/features/registries/components/new-registration/new-registration.component.ts b/src/app/features/registries/components/new-registration/new-registration.component.ts index 62e4b8e61..8fc36948b 100644 --- a/src/app/features/registries/components/new-registration/new-registration.component.ts +++ b/src/app/features/registries/components/new-registration/new-registration.component.ts @@ -8,21 +8,22 @@ import { Select } from 'primeng/select'; import { debounceTime, distinctUntilChanged, filter, Subject, take } from 'rxjs'; -import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, DestroyRef, effect, inject, signal } from '@angular/core'; import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { UserSelectors } from '@core/store/user'; +import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; import { ToastService } from '@osf/shared/services/toast.service'; -import { GetRegistryProvider } from '@shared/stores/registration-provider'; +import { GetRegistryProvider, RegistrationProviderSelectors } from '@shared/stores/registration-provider'; import { CreateDraft, GetProjects, GetProviderSchemas, RegistriesSelectors } from '../../store'; @Component({ selector: 'osf-new-registration', - imports: [SubHeaderComponent, TranslatePipe, Card, Button, ReactiveFormsModule, Select], + imports: [Button, Card, Select, ReactiveFormsModule, LoadingSpinnerComponent, SubHeaderComponent, TranslatePipe], templateUrl: './new-registration.component.html', styleUrl: './new-registration.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -37,11 +38,14 @@ export class NewRegistrationComponent { readonly user = select(UserSelectors.getCurrentUser); readonly projects = select(RegistriesSelectors.getProjects); readonly providerSchemas = select(RegistriesSelectors.getProviderSchemas); + readonly provider = select(RegistrationProviderSelectors.getBrandedProvider); readonly isDraftSubmitting = select(RegistriesSelectors.isDraftSubmitting); readonly isProvidersLoading = select(RegistriesSelectors.isProvidersLoading); readonly isProjectsLoading = select(RegistriesSelectors.isProjectsLoading); private readonly draftRegistration = select(RegistriesSelectors.getDraftRegistration); + readonly canShowForm = computed(() => !this.isProvidersLoading() && !!this.provider()?.allowSubmissions); + private readonly actions = createDispatchMap({ getProvider: GetRegistryProvider, getProjects: GetProjects, @@ -62,6 +66,7 @@ export class NewRegistrationComponent { this.loadInitialData(); this.setupDefaultSchema(); this.setupProjectFilter(); + this.setupSubmissionsAccessCheck(); } onProjectFilter(value: string) { @@ -123,4 +128,15 @@ export class NewRegistrationComponent { } }); } + + private setupSubmissionsAccessCheck() { + effect(() => { + const provider = this.provider(); + + if (provider && !provider.allowSubmissions) { + this.toastService.showError('registries.new.registryClosedForSubmissions'); + this.router.navigate(['/registries', provider.id]); + } + }); + } } diff --git a/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.html b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.html index cc671860b..e8f7a46ba 100644 --- a/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.html +++ b/src/app/features/registries/components/registry-provider-hero/registry-provider-hero.component.html @@ -13,11 +13,13 @@ } - + @if (!isProviderLoading() && provider()!.allowSubmissions) { + + }
diff --git a/src/app/shared/mappers/registration-provider.mapper.ts b/src/app/shared/mappers/registration-provider.mapper.ts index 8b7fefd62..d97c2ebf8 100644 --- a/src/app/shared/mappers/registration-provider.mapper.ts +++ b/src/app/shared/mappers/registration-provider.mapper.ts @@ -34,6 +34,7 @@ export class RegistrationProviderMapper { : null, iri: response.links.iri, reviewsWorkflow: response.attributes.reviews_workflow, + allowSubmissions: response.attributes.allow_submissions, }; } } diff --git a/src/app/shared/models/provider/registry-provider.model.ts b/src/app/shared/models/provider/registry-provider.model.ts index fabd9d824..1c914acd9 100644 --- a/src/app/shared/models/provider/registry-provider.model.ts +++ b/src/app/shared/models/provider/registry-provider.model.ts @@ -10,4 +10,5 @@ export interface RegistryProviderDetails { brand: BrandModel | null; iri: string; reviewsWorkflow: string; + allowSubmissions: boolean; } diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 768a18305..e31380f1c 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -2511,7 +2511,8 @@ }, "selectProject": "Select your project", "createDraft": "Create draft", - "createdSuccessfully": "Draft created successfully" + "createdSuccessfully": "Draft created successfully", + "registryClosedForSubmissions": "This registry is closed for new submissions. Please start a new registration with a different registry." }, "deleteDraft": "Delete Draft", "confirmDeleteDraft": "Are you sure you want to delete this draft registration?",