diff --git a/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.spec.ts b/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.spec.ts index 385ae946b..40e29ec95 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.spec.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.spec.ts @@ -1,47 +1,55 @@ +import { Store } from '@ngxs/store'; + import { MockComponent } from 'ng-mocks'; +import { signal, WritableSignal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; import { AffiliatedInstitutionSelectComponent } from '@osf/shared/components/affiliated-institution-select/affiliated-institution-select.component'; -import { InstitutionsSelectors } from '@osf/shared/stores/institutions'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; +import { + FetchResourceInstitutions, + FetchUserInstitutions, + InstitutionsSelectors, + UpdateResourceInstitutions, +} from '@osf/shared/stores/institutions'; import { RegistriesAffiliatedInstitutionComponent } from './registries-affiliated-institution.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { MOCK_INSTITUTION } from '@testing/mocks/institution.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('RegistriesAffiliatedInstitutionComponent', () => { let component: RegistriesAffiliatedInstitutionComponent; let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; + let store: Store; + let resourceInstitutionsSignal: WritableSignal; - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build(); + beforeEach(() => { + resourceInstitutionsSignal = signal([]); - await TestBed.configureTestingModule({ - imports: [ - RegistriesAffiliatedInstitutionComponent, - OSFTestingModule, - MockComponent(AffiliatedInstitutionSelectComponent), - ], + TestBed.configureTestingModule({ + imports: [RegistriesAffiliatedInstitutionComponent, MockComponent(AffiliatedInstitutionSelectComponent)], providers: [ - { provide: ActivatedRoute, useValue: mockActivatedRoute }, + provideOSFCore(), provideMockStore({ signals: [ { selector: InstitutionsSelectors.getUserInstitutions, value: [] }, { selector: InstitutionsSelectors.areUserInstitutionsLoading, value: false }, - { selector: InstitutionsSelectors.getResourceInstitutions, value: [] }, + { selector: InstitutionsSelectors.getResourceInstitutions, value: resourceInstitutionsSignal }, { selector: InstitutionsSelectors.areResourceInstitutionsLoading, value: false }, { selector: InstitutionsSelectors.areResourceInstitutionsSubmitting, value: false }, ], }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(RegistriesAffiliatedInstitutionComponent); component = fixture.componentInstance; + fixture.componentRef.setInput('draftId', 'draft-1'); fixture.detectChanges(); }); @@ -49,27 +57,26 @@ describe('RegistriesAffiliatedInstitutionComponent', () => { expect(component).toBeTruthy(); }); - it('should dispatch updateResourceInstitutions on selection', () => { - const actionsMock = { - updateResourceInstitutions: jest.fn(), - fetchUserInstitutions: jest.fn(), - fetchResourceInstitutions: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); - const selected = [{ id: 'i2' }] as any; - component.institutionsSelected(selected); - expect(actionsMock.updateResourceInstitutions).toHaveBeenCalledWith('draft-1', 8, selected); + it('should dispatch fetchUserInstitutions and fetchResourceInstitutions on init', () => { + expect(store.dispatch).toHaveBeenCalledWith(new FetchUserInstitutions()); + expect(store.dispatch).toHaveBeenCalledWith( + new FetchResourceInstitutions('draft-1', ResourceType.DraftRegistration) + ); }); - it('should fetch user and resource institutions on init', () => { - const actionsMock = { - updateResourceInstitutions: jest.fn(), - fetchUserInstitutions: jest.fn(), - fetchResourceInstitutions: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); - component.ngOnInit(); - expect(actionsMock.fetchUserInstitutions).toHaveBeenCalled(); - expect(actionsMock.fetchResourceInstitutions).toHaveBeenCalledWith('draft-1', 8); + it('should sync selectedInstitutions when resourceInstitutions emits', () => { + const institutions: Institution[] = [MOCK_INSTITUTION as Institution]; + resourceInstitutionsSignal.set(institutions); + fixture.detectChanges(); + expect(component.selectedInstitutions()).toEqual(institutions); + }); + + it('should dispatch updateResourceInstitutions on selection', () => { + (store.dispatch as jest.Mock).mockClear(); + const selected: Institution[] = [MOCK_INSTITUTION as Institution]; + component.institutionsSelected(selected); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateResourceInstitutions('draft-1', ResourceType.DraftRegistration, selected) + ); }); }); diff --git a/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.ts b/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.ts index 5fa7e1306..a16d71d2d 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-affiliated-institution/registries-affiliated-institution.component.ts @@ -4,8 +4,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Card } from 'primeng/card'; -import { ChangeDetectionStrategy, Component, effect, inject, OnInit, signal } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; +import { ChangeDetectionStrategy, Component, effect, input, OnInit, signal } from '@angular/core'; import { AffiliatedInstitutionSelectComponent } from '@osf/shared/components/affiliated-institution-select/affiliated-institution-select.component'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; @@ -25,8 +24,7 @@ import { changeDetection: ChangeDetectionStrategy.OnPush, }) export class RegistriesAffiliatedInstitutionComponent implements OnInit { - private readonly route = inject(ActivatedRoute); - private readonly draftId = this.route.snapshot.params['id']; + draftId = input.required(); selectedInstitutions = signal([]); @@ -53,10 +51,10 @@ export class RegistriesAffiliatedInstitutionComponent implements OnInit { ngOnInit() { this.actions.fetchUserInstitutions(); - this.actions.fetchResourceInstitutions(this.draftId, ResourceType.DraftRegistration); + this.actions.fetchResourceInstitutions(this.draftId(), ResourceType.DraftRegistration); } institutionsSelected(institutions: Institution[]) { - this.actions.updateResourceInstitutions(this.draftId, ResourceType.DraftRegistration, institutions); + this.actions.updateResourceInstitutions(this.draftId(), ResourceType.DraftRegistration, institutions); } } diff --git a/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.spec.ts b/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.spec.ts index 1ee6a31b7..aecb80277 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.spec.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.spec.ts @@ -1,40 +1,59 @@ +import { Store } from '@ngxs/store'; + import { MockComponent, MockProvider } from 'ng-mocks'; -import { of } from 'rxjs'; +import { Subject } from 'rxjs'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormControl } from '@angular/forms'; -import { ActivatedRoute } from '@angular/router'; import { UserSelectors } from '@core/store/user'; +import { AddContributorType } from '@osf/shared/enums/contributors/add-contributor-type.enum'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ToastService } from '@osf/shared/services/toast.service'; -import { ContributorsSelectors } from '@osf/shared/stores/contributors/contributors.selectors'; +import { + BulkAddContributors, + BulkUpdateContributors, + ContributorsSelectors, + DeleteContributor, + GetAllContributors, + LoadMoreContributors, + ResetContributorsState, +} from '@osf/shared/stores/contributors'; import { ContributorsTableComponent } from '@shared/components/contributors/contributors-table/contributors-table.component'; +import { ContributorModel } from '@shared/models/contributors/contributor.model'; +import { ContributorDialogAddModel } from '@shared/models/contributors/contributor-dialog-add.model'; import { RegistriesContributorsComponent } from './registries-contributors.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { CustomConfirmationServiceMockBuilder } from '@testing/providers/custom-confirmation-provider.mock'; -import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; -import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { + MOCK_CONTRIBUTOR, + MOCK_CONTRIBUTOR_ADD, + MOCK_CONTRIBUTOR_WITHOUT_HISTORY, +} from '@testing/mocks/contributors.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; +import { + CustomConfirmationServiceMockBuilder, + CustomConfirmationServiceMockType, +} from '@testing/providers/custom-confirmation-provider.mock'; +import { + CustomDialogServiceMockBuilder, + CustomDialogServiceMockType, +} from '@testing/providers/custom-dialog-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMockBuilder } from '@testing/providers/toast-provider.mock'; +import { ToastServiceMockBuilder, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; describe('RegistriesContributorsComponent', () => { let component: RegistriesContributorsComponent; let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; - let mockCustomDialogService: ReturnType; - let mockCustomConfirmationService: ReturnType; - let mockToast: ReturnType; + let store: Store; + let mockCustomDialogService: CustomDialogServiceMockType; + let mockCustomConfirmationService: CustomConfirmationServiceMockType; + let mockToast: ToastServiceMockType; - const initialContributors = [ - { id: '1', userId: 'u1', fullName: 'A', permission: 2 }, - { id: '2', userId: 'u2', fullName: 'B', permission: 1 }, - ] as any[]; + const initialContributors: ContributorModel[] = [MOCK_CONTRIBUTOR, MOCK_CONTRIBUTOR_WITHOUT_HISTORY]; beforeAll(() => { if (typeof (globalThis as any).structuredClone !== 'function') { @@ -46,88 +65,157 @@ describe('RegistriesContributorsComponent', () => { } }); - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build(); + beforeEach(() => { mockCustomDialogService = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); mockCustomConfirmationService = CustomConfirmationServiceMockBuilder.create().build(); mockToast = ToastServiceMockBuilder.create().build(); - await TestBed.configureTestingModule({ - imports: [RegistriesContributorsComponent, OSFTestingModule, MockComponent(ContributorsTableComponent)], + TestBed.configureTestingModule({ + imports: [RegistriesContributorsComponent, MockComponent(ContributorsTableComponent)], providers: [ - MockProvider(ActivatedRoute, mockActivatedRoute), + provideOSFCore(), MockProvider(CustomDialogService, mockCustomDialogService), MockProvider(CustomConfirmationService, mockCustomConfirmationService), MockProvider(ToastService, mockToast), provideMockStore({ signals: [ - { selector: UserSelectors.getCurrentUser, value: { id: 'u1' } }, + { selector: UserSelectors.getCurrentUser, value: { id: MOCK_CONTRIBUTOR.userId } }, { selector: ContributorsSelectors.getContributors, value: initialContributors }, { selector: ContributorsSelectors.isContributorsLoading, value: false }, + { selector: ContributorsSelectors.getContributorsTotalCount, value: 2 }, + { selector: ContributorsSelectors.isContributorsLoadingMore, value: false }, + { selector: ContributorsSelectors.getContributorsPageSize, value: 10 }, ], }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(RegistriesContributorsComponent); component = fixture.componentInstance; fixture.componentRef.setInput('control', new FormControl([])); - const mockActions = { - getContributors: jest.fn().mockReturnValue(of({})), - updateContributor: jest.fn().mockReturnValue(of({})), - addContributor: jest.fn().mockReturnValue(of({})), - deleteContributor: jest.fn().mockReturnValue(of({})), - bulkUpdateContributors: jest.fn().mockReturnValue(of({})), - bulkAddContributors: jest.fn().mockReturnValue(of({})), - resetContributorsState: jest.fn().mockRejectedValue(of({})), - } as any; - Object.defineProperty(component, 'actions', { value: mockActions }); + fixture.componentRef.setInput('draftId', 'draft-1'); fixture.detectChanges(); }); - it('should request contributors on init', () => { - const actions = (component as any).actions; - expect(actions.getContributors).toHaveBeenCalledWith('draft-1', ResourceType.DraftRegistration); + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should dispatch getContributors on init', () => { + expect(store.dispatch).toHaveBeenCalledWith(new GetAllContributors('draft-1', ResourceType.DraftRegistration)); }); it('should cancel changes and reset local contributors', () => { - (component as any).contributors.set([{ id: '3' }]); + component.contributors.set([{ ...MOCK_CONTRIBUTOR, id: 'changed' }]); component.cancel(); expect(component.contributors()).toEqual(JSON.parse(JSON.stringify(initialContributors))); }); it('should save changed contributors and show success toast', () => { - (component as any).contributors.set([{ ...initialContributors[0] }, { ...initialContributors[1], permission: 2 }]); + const changedContributor = { ...MOCK_CONTRIBUTOR_WITHOUT_HISTORY, permission: MOCK_CONTRIBUTOR.permission }; + component.contributors.set([{ ...MOCK_CONTRIBUTOR }, changedContributor]); + (store.dispatch as jest.Mock).mockClear(); component.save(); - expect(mockToast.showSuccess).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith( + new BulkUpdateContributors('draft-1', ResourceType.DraftRegistration, [changedContributor]) + ); + expect(mockToast.showSuccess).toHaveBeenCalledWith( + 'project.contributors.toastMessages.multipleUpdateSuccessMessage' + ); + }); + + it('should bulk add registered contributors and show toast when add dialog closes', () => { + const dialogClose$ = new Subject(); + mockCustomDialogService.open.mockReturnValue({ onClose: dialogClose$, close: jest.fn() } as any); + (store.dispatch as jest.Mock).mockClear(); + + component.openAddContributorDialog(); + dialogClose$.next({ type: AddContributorType.Registered, data: [MOCK_CONTRIBUTOR_ADD] }); + + expect(store.dispatch).toHaveBeenCalledWith( + new BulkAddContributors('draft-1', ResourceType.DraftRegistration, [MOCK_CONTRIBUTOR_ADD]) + ); + expect(mockToast.showSuccess).toHaveBeenCalledWith('project.contributors.toastMessages.multipleAddSuccessMessage'); }); - it('should open add contributor dialog', () => { + it('should switch to unregistered dialog when add dialog closes with unregistered type', () => { + const dialogClose$ = new Subject(); + mockCustomDialogService.open.mockReturnValue({ onClose: dialogClose$, close: jest.fn() } as any); + const spy = jest.spyOn(component, 'openAddUnregisteredContributorDialog').mockImplementation(() => {}); + component.openAddContributorDialog(); - expect(mockCustomDialogService.open).toHaveBeenCalled(); + dialogClose$.next({ type: AddContributorType.Unregistered, data: [] }); + + expect(spy).toHaveBeenCalled(); }); - it('should open add unregistered contributor dialog', () => { + it('should bulk add unregistered contributor and show toast with name param', () => { + const dialogClose$ = new Subject(); + const unregisteredAdd = { ...MOCK_CONTRIBUTOR_ADD, fullName: 'Test User' }; + mockCustomDialogService.open.mockReturnValue({ onClose: dialogClose$, close: jest.fn() } as any); + (store.dispatch as jest.Mock).mockClear(); + component.openAddUnregisteredContributorDialog(); - expect(mockCustomDialogService.open).toHaveBeenCalled(); + dialogClose$.next({ type: AddContributorType.Unregistered, data: [unregisteredAdd] }); + + expect(store.dispatch).toHaveBeenCalledWith( + new BulkAddContributors('draft-1', ResourceType.DraftRegistration, [unregisteredAdd]) + ); + expect(mockToast.showSuccess).toHaveBeenCalledWith('project.contributors.toastMessages.addSuccessMessage', { + name: 'Test User', + }); + }); + + it('should switch to registered dialog when unregistered dialog closes with registered type', () => { + const dialogClose$ = new Subject(); + mockCustomDialogService.open.mockReturnValue({ onClose: dialogClose$, close: jest.fn() } as any); + const spy = jest.spyOn(component, 'openAddContributorDialog').mockImplementation(() => {}); + + component.openAddUnregisteredContributorDialog(); + dialogClose$.next({ type: AddContributorType.Registered, data: [] }); + + expect(spy).toHaveBeenCalled(); }); it('should remove contributor after confirmation and show success toast', () => { - const contributor = { id: '2', userId: 'u2', fullName: 'B' } as any; - component.removeContributor(contributor); + (store.dispatch as jest.Mock).mockClear(); + component.removeContributor(MOCK_CONTRIBUTOR_WITHOUT_HISTORY); expect(mockCustomConfirmationService.confirmDelete).toHaveBeenCalled(); - const call = (mockCustomConfirmationService.confirmDelete as any).mock.calls[0][0]; + const call = (mockCustomConfirmationService.confirmDelete as jest.Mock).mock.calls[0][0]; call.onConfirm(); - expect(mockToast.showSuccess).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith( + new DeleteContributor('draft-1', ResourceType.DraftRegistration, MOCK_CONTRIBUTOR_WITHOUT_HISTORY.userId) + ); + expect(mockToast.showSuccess).toHaveBeenCalledWith('project.contributors.removeDialog.successMessage', { + name: MOCK_CONTRIBUTOR_WITHOUT_HISTORY.fullName, + }); + }); + + it('should return true for hasChanges when contributors differ from initial', () => { + component.contributors.set([{ ...MOCK_CONTRIBUTOR, id: 'changed' }]); + expect(component.hasChanges).toBe(true); + }); + + it('should return false for hasChanges when contributors match initial', () => { + expect(component.hasChanges).toBe(false); + }); + + it('should dispatch resetContributorsState on destroy', () => { + (store.dispatch as jest.Mock).mockClear(); + component.ngOnDestroy(); + expect(store.dispatch).toHaveBeenCalledWith(new ResetContributorsState()); + }); + + it('should dispatch loadMoreContributors', () => { + (store.dispatch as jest.Mock).mockClear(); + component.loadMoreContributors(); + expect(store.dispatch).toHaveBeenCalledWith(new LoadMoreContributors('draft-1', ResourceType.DraftRegistration)); }); it('should mark control touched and dirty on focus out', () => { - const control = new FormControl([]); - const spy = jest.spyOn(control, 'updateValueAndValidity'); - fixture.componentRef.setInput('control', control); component.onFocusOut(); - expect(control.touched).toBe(true); - expect(control.dirty).toBe(true); - expect(spy).toHaveBeenCalled(); + expect(component.control().touched).toBe(true); + expect(component.control().dirty).toBe(true); }); }); diff --git a/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.ts b/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.ts index c69bc4e41..af89f0281 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-contributors/registries-contributors.component.ts @@ -4,9 +4,8 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Card } from 'primeng/card'; -import { TableModule } from 'primeng/table'; -import { filter, map, of } from 'rxjs'; +import { EMPTY, filter, switchMap, tap } from 'rxjs'; import { ChangeDetectionStrategy, @@ -20,9 +19,8 @@ import { OnInit, signal, } from '@angular/core'; -import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; -import { FormControl, FormsModule } from '@angular/forms'; -import { ActivatedRoute } from '@angular/router'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormControl } from '@angular/forms'; import { AddContributorDialogComponent, @@ -51,21 +49,19 @@ import { TableParameters } from '@shared/models/table-parameters.model'; @Component({ selector: 'osf-registries-contributors', - imports: [FormsModule, TableModule, ContributorsTableComponent, TranslatePipe, Card, Button], + imports: [ContributorsTableComponent, TranslatePipe, Card, Button], templateUrl: './registries-contributors.component.html', styleUrl: './registries-contributors.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class RegistriesContributorsComponent implements OnInit, OnDestroy { control = input.required(); + draftId = input.required(); - readonly destroyRef = inject(DestroyRef); - readonly customDialogService = inject(CustomDialogService); - readonly toastService = inject(ToastService); - readonly customConfirmationService = inject(CustomConfirmationService); - - private readonly route = inject(ActivatedRoute); - private readonly draftId = toSignal(this.route.params.pipe(map((params) => params['id'])) ?? of(undefined)); + private readonly destroyRef = inject(DestroyRef); + private readonly customDialogService = inject(CustomDialogService); + private readonly toastService = inject(ToastService); + private readonly customConfirmationService = inject(CustomConfirmationService); initialContributors = select(ContributorsSelectors.getContributors); contributors = signal([]); @@ -112,11 +108,10 @@ export class RegistriesContributorsComponent implements OnInit, OnDestroy { } onFocusOut() { - if (this.control()) { - this.control().markAsTouched(); - this.control().markAsDirty(); - this.control().updateValueAndValidity(); - } + const control = this.control(); + control.markAsTouched(); + control.markAsDirty(); + control.updateValueAndValidity(); } cancel() { @@ -142,20 +137,21 @@ export class RegistriesContributorsComponent implements OnInit, OnDestroy { }) .onClose.pipe( filter((res: ContributorDialogAddModel) => !!res), - takeUntilDestroyed(this.destroyRef) - ) - .subscribe((res: ContributorDialogAddModel) => { - if (res.type === AddContributorType.Unregistered) { - this.openAddUnregisteredContributorDialog(); - } else { - this.actions + switchMap((res: ContributorDialogAddModel) => { + if (res.type === AddContributorType.Unregistered) { + this.openAddUnregisteredContributorDialog(); + return EMPTY; + } + + return this.actions .bulkAddContributors(this.draftId(), ResourceType.DraftRegistration, res.data) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(() => - this.toastService.showSuccess('project.contributors.toastMessages.multipleAddSuccessMessage') + .pipe( + tap(() => this.toastService.showSuccess('project.contributors.toastMessages.multipleAddSuccessMessage')) ); - } - }); + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(); } openAddUnregisteredContributorDialog() { @@ -166,19 +162,22 @@ export class RegistriesContributorsComponent implements OnInit, OnDestroy { }) .onClose.pipe( filter((res: ContributorDialogAddModel) => !!res), + switchMap((res) => { + if (res.type === AddContributorType.Registered) { + this.openAddContributorDialog(); + return EMPTY; + } + + const params = { name: res.data[0].fullName }; + return this.actions + .bulkAddContributors(this.draftId(), ResourceType.DraftRegistration, res.data) + .pipe( + tap(() => this.toastService.showSuccess('project.contributors.toastMessages.addSuccessMessage', params)) + ); + }), takeUntilDestroyed(this.destroyRef) ) - .subscribe((res: ContributorDialogAddModel) => { - if (res.type === AddContributorType.Registered) { - this.openAddContributorDialog(); - } else { - const params = { name: res.data[0].fullName }; - - this.actions.bulkAddContributors(this.draftId(), ResourceType.DraftRegistration, res.data).subscribe({ - next: () => this.toastService.showSuccess('project.contributors.toastMessages.addSuccessMessage', params), - }); - } - }); + .subscribe(); } removeContributor(contributor: ContributorModel) { diff --git a/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.scss b/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.scss index 7f863186d..e69de29bb 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.scss +++ b/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.scss @@ -1,4 +0,0 @@ -.highlight-block { - padding: 0.5rem; - background-color: var(--bg-blue-2); -} diff --git a/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.spec.ts b/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.spec.ts index bd5a86de0..18a8e69c4 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.spec.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.spec.ts @@ -1,48 +1,57 @@ +import { Store } from '@ngxs/store'; + import { MockComponent } from 'ng-mocks'; +import { signal, WritableSignal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormControl, FormGroup } from '@angular/forms'; -import { ActivatedRoute } from '@angular/router'; -import { RegistriesSelectors } from '@osf/features/registries/store'; +import { FetchLicenses, RegistriesSelectors, SaveLicense } from '@osf/features/registries/store'; import { LicenseComponent } from '@osf/shared/components/license/license.component'; +import { LicenseModel } from '@shared/models/license/license.model'; import { RegistriesLicenseComponent } from './registries-license.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('RegistriesLicenseComponent', () => { let component: RegistriesLicenseComponent; let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; + let store: Store; + let licensesSignal: WritableSignal; + let selectedLicenseSignal: WritableSignal<{ id: string; options?: Record } | null>; + let draftRegistrationSignal: WritableSignal | null>; + + const mockLicense: LicenseModel = { id: 'lic-1', name: 'MIT', requiredFields: [], url: '', text: '' }; + const mockDefaultLicense: LicenseModel = { id: 'default-1', name: 'Default', requiredFields: [], url: '', text: '' }; - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build(); + beforeEach(() => { + licensesSignal = signal([]); + selectedLicenseSignal = signal<{ id: string; options?: Record } | null>(null); + draftRegistrationSignal = signal | null>({ + providerId: 'osf', + }); - await TestBed.configureTestingModule({ - imports: [RegistriesLicenseComponent, OSFTestingModule, MockComponent(LicenseComponent)], + TestBed.configureTestingModule({ + imports: [RegistriesLicenseComponent, MockComponent(LicenseComponent)], providers: [ - { provide: ActivatedRoute, useValue: mockActivatedRoute }, + provideOSFCore(), provideMockStore({ signals: [ - { selector: RegistriesSelectors.getLicenses, value: [] }, - { selector: RegistriesSelectors.getSelectedLicense, value: null }, - { selector: RegistriesSelectors.getDraftRegistration, value: { providerId: 'osf' } }, + { selector: RegistriesSelectors.getLicenses, value: licensesSignal }, + { selector: RegistriesSelectors.getSelectedLicense, value: selectedLicenseSignal }, + { selector: RegistriesSelectors.getDraftRegistration, value: draftRegistrationSignal }, ], }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(RegistriesLicenseComponent); component = fixture.componentInstance; fixture.componentRef.setInput('control', new FormGroup({ id: new FormControl('') })); - const mockActions = { - fetchLicenses: jest.fn().mockReturnValue({}), - saveLicense: jest.fn().mockReturnValue({}), - } as any; - Object.defineProperty(component, 'actions', { value: mockActions }); + fixture.componentRef.setInput('draftId', 'draft-1'); fixture.detectChanges(); }); @@ -50,36 +59,90 @@ describe('RegistriesLicenseComponent', () => { expect(component).toBeTruthy(); }); - it('should fetch licenses on init when draft present', () => { - expect((component as any).actions.fetchLicenses).toHaveBeenCalledWith('osf'); + it('should dispatch fetchLicenses on init when draft present', () => { + expect(store.dispatch).toHaveBeenCalledWith(new FetchLicenses('osf')); + }); + + it('should fetch licenses only once even if draft re-emits', () => { + (store.dispatch as jest.Mock).mockClear(); + draftRegistrationSignal.set({ providerId: 'other' }); + fixture.detectChanges(); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(FetchLicenses)); + }); + + it('should sync selected license to control when license exists in list', () => { + licensesSignal.set([mockLicense]); + selectedLicenseSignal.set({ id: 'lic-1' }); + fixture.detectChanges(); + expect(component.control().get('id')?.value).toBe('lic-1'); + }); + + it('should apply default license and save when no selected license', () => { + (store.dispatch as jest.Mock).mockClear(); + draftRegistrationSignal.set({ providerId: 'osf', defaultLicenseId: 'default-1' }); + licensesSignal.set([mockDefaultLicense]); + fixture.detectChanges(); + expect(component.control().get('id')?.value).toBe('default-1'); + expect(store.dispatch).toHaveBeenCalledWith(new SaveLicense('draft-1', 'default-1')); + }); + + it('should apply default license but not save when it has required fields', () => { + (store.dispatch as jest.Mock).mockClear(); + const licenseWithFields: LicenseModel = { + id: 'default-2', + name: 'CC-BY', + requiredFields: ['year'], + url: '', + text: '', + }; + draftRegistrationSignal.set({ providerId: 'osf', defaultLicenseId: 'default-2' }); + licensesSignal.set([licenseWithFields]); + fixture.detectChanges(); + expect(component.control().get('id')?.value).toBe('default-2'); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(SaveLicense)); + }); + + it('should prefer selected license over default license', () => { + licensesSignal.set([mockDefaultLicense, mockLicense]); + draftRegistrationSignal.set({ providerId: 'osf', defaultLicenseId: 'default-1' }); + selectedLicenseSignal.set({ id: 'lic-1' }); + fixture.detectChanges(); + expect(component.control().get('id')?.value).toBe('lic-1'); }); it('should set control id and save license when selecting simple license', () => { - const saveSpy = jest.spyOn((component as any).actions, 'saveLicense'); - component.selectLicense({ id: 'lic-1', requiredFields: [] } as any); - expect((component.control() as FormGroup).get('id')?.value).toBe('lic-1'); - expect(saveSpy).toHaveBeenCalledWith('draft-1', 'lic-1'); + (store.dispatch as jest.Mock).mockClear(); + component.selectLicense(mockLicense); + expect(component.control().get('id')?.value).toBe('lic-1'); + expect(store.dispatch).toHaveBeenCalledWith(new SaveLicense('draft-1', 'lic-1')); }); it('should not save when license has required fields', () => { - const saveSpy = jest.spyOn((component as any).actions, 'saveLicense'); - component.selectLicense({ id: 'lic-2', requiredFields: ['year'] } as any); - expect(saveSpy).not.toHaveBeenCalled(); + (store.dispatch as jest.Mock).mockClear(); + component.selectLicense({ id: 'lic-2', name: 'CC-BY', requiredFields: ['year'], url: '', text: '' }); + expect(store.dispatch).not.toHaveBeenCalled(); }); - it('should create license with options', () => { - const saveSpy = jest.spyOn((component as any).actions, 'saveLicense'); - component.createLicense({ id: 'lic-3', licenseOptions: { year: '2024', copyrightHolders: 'Me' } as any }); - expect(saveSpy).toHaveBeenCalledWith('draft-1', 'lic-3', { year: '2024', copyrightHolders: 'Me' }); + it('should dispatch saveLicense with options on createLicense', () => { + (store.dispatch as jest.Mock).mockClear(); + component.createLicense({ id: 'lic-3', licenseOptions: { year: '2024', copyrightHolders: 'Me' } }); + expect(store.dispatch).toHaveBeenCalledWith( + new SaveLicense('draft-1', 'lic-3', { year: '2024', copyrightHolders: 'Me' }) + ); + }); + + it('should not apply default license when defaultLicenseId is not in the list', () => { + (store.dispatch as jest.Mock).mockClear(); + draftRegistrationSignal.set({ providerId: 'osf', defaultLicenseId: 'non-existent' }); + licensesSignal.set([mockLicense]); + fixture.detectChanges(); + expect(component.control().get('id')?.value).toBe(''); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(SaveLicense)); }); it('should mark control on focus out', () => { - const control = new FormGroup({ id: new FormControl('') }); - fixture.componentRef.setInput('control', control); - const spy = jest.spyOn(control, 'updateValueAndValidity'); component.onFocusOut(); - expect(control.touched).toBe(true); - expect(control.dirty).toBe(true); - expect(spy).toHaveBeenCalled(); + expect(component.control().touched).toBe(true); + expect(component.control().dirty).toBe(true); }); }); diff --git a/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.ts b/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.ts index a33da20e0..7225338ca 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.ts @@ -5,113 +5,100 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Card } from 'primeng/card'; import { Message } from 'primeng/message'; -import { ChangeDetectionStrategy, Component, effect, inject, input } from '@angular/core'; -import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { ActivatedRoute } from '@angular/router'; +import { ChangeDetectionStrategy, Component, effect, inject, input, signal } from '@angular/core'; +import { FormGroup, ReactiveFormsModule } from '@angular/forms'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { FetchLicenses, RegistriesSelectors, SaveLicense } from '@osf/features/registries/store'; import { LicenseComponent } from '@osf/shared/components/license/license.component'; -import { InputLimits } from '@osf/shared/constants/input-limits.const'; import { INPUT_VALIDATION_MESSAGES } from '@osf/shared/constants/input-validation-messages.const'; import { LicenseModel, LicenseOptions } from '@shared/models/license/license.model'; @Component({ selector: 'osf-registries-license', - imports: [FormsModule, ReactiveFormsModule, LicenseComponent, Card, TranslatePipe, Message], + imports: [ReactiveFormsModule, LicenseComponent, Card, TranslatePipe, Message], templateUrl: './registries-license.component.html', styleUrl: './registries-license.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class RegistriesLicenseComponent { control = input.required(); + draftId = input.required(); - private readonly route = inject(ActivatedRoute); private readonly environment = inject(ENVIRONMENT); - private readonly draftId = this.route.snapshot.params['id']; actions = createDispatchMap({ fetchLicenses: FetchLicenses, saveLicense: SaveLicense }); - licenses = select(RegistriesSelectors.getLicenses); - inputLimits = InputLimits; + licenses = select(RegistriesSelectors.getLicenses); selectedLicense = select(RegistriesSelectors.getSelectedLicense); draftRegistration = select(RegistriesSelectors.getDraftRegistration); - currentYear = new Date(); - readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; - private isLoaded = false; + private readonly licensesLoaded = signal(false); constructor() { effect(() => { - if (this.draftRegistration() && !this.isLoaded) { + if (this.draftRegistration() && !this.licensesLoaded()) { this.actions.fetchLicenses(this.draftRegistration()?.providerId ?? this.environment.defaultProvider); - this.isLoaded = true; + this.licensesLoaded.set(true); } }); effect(() => { - const selectedLicense = this.selectedLicense(); - if (!selectedLicense) { - return; - } - - this.control().patchValue({ - id: selectedLicense.id, - }); - }); - - effect(() => { + const control = this.control(); const licenses = this.licenses(); const selectedLicense = this.selectedLicense(); const defaultLicenseId = this.draftRegistration()?.defaultLicenseId; - if (!licenses.length) { + if (selectedLicense && licenses.some((l) => l.id === selectedLicense.id)) { + control.patchValue({ id: selectedLicense.id }); return; } - if ( - defaultLicenseId && - (!selectedLicense?.id || !licenses.find((license) => license.id === selectedLicense?.id)) - ) { - const defaultLicense = licenses.find((license) => license.id === defaultLicenseId); - if (defaultLicense) { - this.control().patchValue({ - id: defaultLicense.id, - }); - this.control().markAsTouched(); - this.control().updateValueAndValidity(); - - if (!defaultLicense.requiredFields.length) { - this.actions.saveLicense(this.draftId, defaultLicense.id); - } - } - } + this.applyDefaultLicense(control, licenses, defaultLicenseId); }); } createLicense(licenseDetails: { id: string; licenseOptions: LicenseOptions }) { - this.actions.saveLicense(this.draftId, licenseDetails.id, licenseDetails.licenseOptions); + this.actions.saveLicense(this.draftId(), licenseDetails.id, licenseDetails.licenseOptions); } selectLicense(license: LicenseModel) { if (license.requiredFields.length) { return; } - this.control().patchValue({ - id: license.id, - }); - this.control().markAsTouched(); - this.control().updateValueAndValidity(); - this.actions.saveLicense(this.draftId, license.id); + + const control = this.control(); + control.patchValue({ id: license.id }); + control.markAsTouched(); + control.updateValueAndValidity(); + this.actions.saveLicense(this.draftId(), license.id); } onFocusOut() { - if (this.control()) { - this.control().markAsTouched(); - this.control().markAsDirty(); - this.control().updateValueAndValidity(); + const control = this.control(); + control.markAsTouched(); + control.markAsDirty(); + control.updateValueAndValidity(); + } + + private applyDefaultLicense(control: FormGroup, licenses: LicenseModel[], defaultLicenseId?: string) { + if (!licenses.length || !defaultLicenseId) { + return; + } + + const defaultLicense = licenses.find((license) => license.id === defaultLicenseId); + if (!defaultLicense) { + return; + } + + control.patchValue({ id: defaultLicense.id }); + control.markAsTouched(); + control.updateValueAndValidity(); + + if (!defaultLicense.requiredFields.length) { + this.actions.saveLicense(this.draftId(), defaultLicense.id); } } } diff --git a/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.html b/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.html index 98f184e8f..04af77dbc 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.html +++ b/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.html @@ -39,7 +39,6 @@

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

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

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

- - - - - + + + + +
{ +describe('RegistriesMetadataStepComponent', () => { + ngMocks.faster(); + let component: RegistriesMetadataStepComponent; let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; - let mockRouter: ReturnType; + let store: Store; + let mockRouter: RouterMockType; + let stepsStateSignal: WritableSignal<{ invalid: boolean }[]>; - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build(); + const mockDraft = { ...MOCK_DRAFT_REGISTRATION, title: 'Test Title', description: 'Test Description' }; + + beforeEach(() => { + const mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build(); mockRouter = RouterMockBuilder.create().withUrl('/registries/osf/draft/draft-1/metadata').build(); + stepsStateSignal = signal<{ invalid: boolean }[]>([{ invalid: true }]); - await TestBed.configureTestingModule({ + TestBed.configureTestingModule({ imports: [ RegistriesMetadataStepComponent, - OSFTestingModule, + MockModule(TextareaModule), ...MockComponents( TextInputComponent, RegistriesContributorsComponent, @@ -47,20 +60,23 @@ describe.skip('RegistriesMetadataStepComponent', () => { ), ], providers: [ - MockProvider(ActivatedRoute, mockActivatedRoute), - MockProvider(Router, mockRouter), - MockProvider(CustomConfirmationService, { confirmDelete: jest.fn() }), + provideOSFCore(), + provideActivatedRouteMock(mockActivatedRoute), + provideRouterMock(mockRouter), + MockCustomConfirmationServiceProvider, provideMockStore({ signals: [ - { selector: RegistriesSelectors.getStepsState, value: { 0: { invalid: false } } }, + { selector: RegistriesSelectors.getDraftRegistration, value: mockDraft }, + { selector: RegistriesSelectors.getStepsState, value: stepsStateSignal }, + { selector: RegistriesSelectors.hasDraftAdminAccess, value: true }, { selector: ContributorsSelectors.getContributors, value: [] }, { selector: SubjectsSelectors.getSelectedSubjects, value: [] }, - { selector: InstitutionsSelectors.getResourceInstitutions, value: [] }, ], }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(RegistriesMetadataStepComponent); component = fixture.componentInstance; fixture.detectChanges(); @@ -70,66 +86,97 @@ describe.skip('RegistriesMetadataStepComponent', () => { expect(component).toBeTruthy(); }); - it('should initialize form with draft data', () => { - expect(component.metadataForm.value.title).toBe(' My Title '); - expect(component.metadataForm.value.description).toBe(' Description '); - expect(component.metadataForm.value.license).toEqual({ id: 'mit' }); + it('should initialize metadataForm with required controls', () => { + expect(component.metadataForm.get('title')).toBeTruthy(); + expect(component.metadataForm.get('description')).toBeTruthy(); + expect(component.metadataForm.get('contributors')).toBeTruthy(); + expect(component.metadataForm.get('subjects')).toBeTruthy(); + expect(component.metadataForm.get('tags')).toBeTruthy(); + expect(component.metadataForm.get('license.id')).toBeTruthy(); }); - it('should compute hasAdminAccess', () => { - expect(component.hasAdminAccess()).toBe(true); + it('should have form invalid when title is empty', () => { + component.metadataForm.patchValue({ title: '', description: 'Valid' }); + expect(component.metadataForm.get('title')?.valid).toBe(false); }); - it('should submit metadata, trim values and navigate to first step', () => { - const actionsMock = { - updateDraft: jest.fn().mockReturnValue({ pipe: () => ({ subscribe: jest.fn() }) }), - deleteDraft: jest.fn(), - clearState: jest.fn(), - updateStepState: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); - const navSpy = jest.spyOn(TestBed.inject(Router), 'navigate'); + it('should submit metadata and navigate to step 1', () => { + component.metadataForm.patchValue({ title: 'New Title', description: 'New Desc' }); + (store.dispatch as jest.Mock).mockClear(); component.submitMetadata(); - expect(actionsMock.updateDraft).toHaveBeenCalledWith('draft-1', { - title: 'My Title', - description: 'Description', - }); - expect(navSpy).toHaveBeenCalledWith(['../1'], { - relativeTo: TestBed.inject(ActivatedRoute), - onSameUrlNavigation: 'reload', - }); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateDraft('draft-1', { title: 'New Title', description: 'New Desc' }) + ); + expect(mockRouter.navigate).toHaveBeenCalledWith( + ['../1'], + expect.objectContaining({ onSameUrlNavigation: 'reload' }) + ); }); - it('should delete draft on confirm and navigate to new registration', () => { - const confirmService = TestBed.inject(CustomConfirmationService) as jest.Mocked as any; - const actionsMock = { - deleteDraft: jest.fn().mockReturnValue({ subscribe: ({ next }: any) => next() }), - clearState: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); - const navSpy = jest.spyOn(TestBed.inject(Router), 'navigateByUrl'); + it('should trim title and description on submit', () => { + component.metadataForm.patchValue({ title: ' Padded Title ', description: ' Padded Desc ' }); + (store.dispatch as jest.Mock).mockClear(); - (confirmService.confirmDelete as jest.Mock).mockImplementation(({ onConfirm }) => onConfirm()); + component.submitMetadata(); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateDraft('draft-1', { title: 'Padded Title', description: 'Padded Desc' }) + ); + }); + + it('should call confirmDelete when deleteDraft is called', () => { component.deleteDraft(); + expect(CustomConfirmationServiceMock.confirmDelete).toHaveBeenCalledWith( + expect.objectContaining({ + headerKey: 'registries.deleteDraft', + messageKey: 'registries.confirmDeleteDraft', + }) + ); + }); - expect(actionsMock.clearState).toHaveBeenCalled(); - expect(navSpy).toHaveBeenCalledWith('/registries/osf/new'); + it('should set isDraftDeleted and navigate on deleteDraft confirm', () => { + CustomConfirmationServiceMock.confirmDelete.mockImplementation(({ onConfirm }: any) => onConfirm()); + (store.dispatch as jest.Mock).mockClear(); + + component.deleteDraft(); + + expect(component.isDraftDeleted).toBe(true); + expect(store.dispatch).toHaveBeenCalledWith(new DeleteDraft('draft-1')); + expect(store.dispatch).toHaveBeenCalledWith(new ClearState()); + expect(mockRouter.navigateByUrl).toHaveBeenCalledWith('/registries/osf/new'); + }); + + it('should skip updates on destroy when isDraftDeleted is true', () => { + (store.dispatch as jest.Mock).mockClear(); + component.isDraftDeleted = true; + component.ngOnDestroy(); + + expect(store.dispatch).not.toHaveBeenCalled(); }); - it('should update step state and draft on destroy if changed', () => { - const actionsMock = { - updateStepState: jest.fn(), - updateDraft: jest.fn(), - } as any; - Object.defineProperty(component, 'actions', { value: actionsMock }); + it('should update step state on destroy when fields are unchanged', () => { + component.metadataForm.patchValue({ title: 'Test Title', description: 'Test Description' }); + (store.dispatch as jest.Mock).mockClear(); + component.ngOnDestroy(); + + expect(store.dispatch).toHaveBeenCalledWith(new UpdateStepState('0', expect.any(Boolean), true)); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(UpdateDraft)); + }); - component.metadataForm.patchValue({ title: 'Changed', description: 'Changed desc' }); - fixture.destroy(); + it('should dispatch updateDraft on destroy when fields have changed', () => { + component.metadataForm.patchValue({ title: 'Changed Title', description: 'Test Description' }); + (store.dispatch as jest.Mock).mockClear(); + component.ngOnDestroy(); + + expect(store.dispatch).toHaveBeenCalledWith(new UpdateStepState('0', expect.any(Boolean), true)); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateDraft('draft-1', expect.objectContaining({ title: 'Changed Title' })) + ); + }); - expect(actionsMock.updateStepState).toHaveBeenCalledWith('0', true, true); - expect(actionsMock.updateDraft).toHaveBeenCalledWith('draft-1', { title: 'Changed', description: 'Changed desc' }); + it('should mark form as touched when step state is invalid on init', () => { + expect(component.metadataForm.touched).toBe(true); }); }); diff --git a/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.ts b/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.ts index bfcc1e5f0..589ec9174 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-metadata-step.component.ts @@ -7,9 +7,10 @@ import { Card } from 'primeng/card'; import { Message } from 'primeng/message'; import { TextareaModule } from 'primeng/textarea'; -import { tap } from 'rxjs'; +import { filter, take, tap } from 'rxjs'; -import { ChangeDetectionStrategy, Component, computed, effect, inject, OnDestroy } from '@angular/core'; +import { ChangeDetectionStrategy, Component, DestroyRef, inject, OnDestroy } from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; @@ -21,7 +22,6 @@ import { findChangedFields } from '@osf/shared/helpers/find-changed-fields'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { ContributorsSelectors } from '@osf/shared/stores/contributors'; import { SubjectsSelectors } from '@osf/shared/stores/subjects'; -import { UserPermissions } from '@shared/enums/user-permissions.enum'; import { ContributorModel } from '@shared/models/contributors/contributor.model'; import { DraftRegistrationModel } from '@shared/models/registration/draft-registration.model'; import { SubjectModel } from '@shared/models/subject/subject.model'; @@ -58,21 +58,18 @@ export class RegistriesMetadataStepComponent implements OnDestroy { private readonly fb = inject(FormBuilder); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); private readonly customConfirmationService = inject(CustomConfirmationService); readonly titleLimit = InputLimits.title.maxLength; - private readonly draftId = this.route.snapshot.params['id']; - readonly draftRegistration = select(RegistriesSelectors.getDraftRegistration); + readonly draftId = this.route.snapshot.params['id']; + + draftRegistration = select(RegistriesSelectors.getDraftRegistration); selectedSubjects = select(SubjectsSelectors.getSelectedSubjects); initialContributors = select(ContributorsSelectors.getContributors); stepsState = select(RegistriesSelectors.getStepsState); - - hasAdminAccess = computed(() => { - const registry = this.draftRegistration(); - if (!registry) return false; - return registry.currentUserPermissions.includes(UserPermissions.Admin); - }); + hasAdminAccess = select(RegistriesSelectors.hasDraftAdminAccess); actions = createDispatchMap({ deleteDraft: DeleteDraft, @@ -89,35 +86,29 @@ export class RegistriesMetadataStepComponent implements OnDestroy { contributors: [[] as ContributorModel[], Validators.required], subjects: [[] as SubjectModel[], Validators.required], tags: [[]], - license: this.fb.group({ - id: ['', Validators.required], - }), + license: this.fb.group({ id: ['', Validators.required] }), }); isDraftDeleted = false; - isFormUpdated = false; constructor() { - effect(() => { - const draft = this.draftRegistration(); - // TODO: This shouldn't be an effect() - if (draft && !this.isFormUpdated) { - this.updateFormValue(draft); - this.isFormUpdated = true; - } - }); + toObservable(this.draftRegistration) + .pipe(filter(Boolean), take(1), takeUntilDestroyed(this.destroyRef)) + .subscribe((draft) => this.updateFormValue(draft)); } - private updateFormValue(data: DraftRegistrationModel): void { - this.metadataForm.patchValue({ - title: data.title, - description: data.description, - license: data.license, - contributors: this.initialContributors(), - subjects: this.selectedSubjects(), - }); - if (this.stepsState()?.[0]?.invalid) { - this.metadataForm.markAllAsTouched(); + ngOnDestroy(): void { + if (!this.isDraftDeleted) { + this.actions.updateStepState('0', this.metadataForm.invalid, true); + const changedFields = findChangedFields( + { title: this.metadataForm.value.title!, description: this.metadataForm.value.description! }, + { title: this.draftRegistration()?.title, description: this.draftRegistration()?.description } + ); + + if (Object.keys(changedFields).length > 0) { + this.actions.updateDraft(this.draftId, changedFields); + this.metadataForm.markAllAsTouched(); + } } } @@ -156,17 +147,17 @@ export class RegistriesMetadataStepComponent implements OnDestroy { }); } - ngOnDestroy(): void { - if (!this.isDraftDeleted) { - this.actions.updateStepState('0', this.metadataForm.invalid, true); - const changedFields = findChangedFields( - { title: this.metadataForm.value.title!, description: this.metadataForm.value.description! }, - { title: this.draftRegistration()?.title, description: this.draftRegistration()?.description } - ); - if (Object.keys(changedFields).length > 0) { - this.actions.updateDraft(this.draftId, changedFields); - this.metadataForm.markAllAsTouched(); - } + private updateFormValue(data: DraftRegistrationModel): void { + this.metadataForm.patchValue({ + title: data.title, + description: data.description, + license: data.license, + contributors: this.initialContributors(), + subjects: this.selectedSubjects(), + }); + + if (this.stepsState()?.[0]?.invalid) { + this.metadataForm.markAllAsTouched(); } } } diff --git a/src/app/features/registries/components/registries-metadata-step/registries-subjects/registries-subjects.component.spec.ts b/src/app/features/registries/components/registries-metadata-step/registries-subjects/registries-subjects.component.spec.ts index 9c8ecbff4..f86e440b3 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-subjects/registries-subjects.component.spec.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-subjects/registries-subjects.component.spec.ts @@ -1,57 +1,58 @@ -import { MockComponent, MockProvider } from 'ng-mocks'; +import { Store } from '@ngxs/store'; -import { of } from 'rxjs'; +import { MockComponent } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormControl } from '@angular/forms'; -import { ActivatedRoute } from '@angular/router'; +import { FormControl, Validators } from '@angular/forms'; import { RegistriesSelectors } from '@osf/features/registries/store'; import { SubjectsComponent } from '@osf/shared/components/subjects/subjects.component'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; -import { SubjectsSelectors } from '@osf/shared/stores/subjects'; +import { + FetchChildrenSubjects, + FetchSelectedSubjects, + FetchSubjects, + SubjectsSelectors, + UpdateResourceSubjects, +} from '@osf/shared/stores/subjects'; +import { SubjectModel } from '@shared/models/subject/subject.model'; import { RegistriesSubjectsComponent } from './registries-subjects.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { MOCK_DRAFT_REGISTRATION } from '@testing/mocks/draft-registration.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('RegistriesSubjectsComponent', () => { let component: RegistriesSubjectsComponent; let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; + let store: Store; - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'draft-1' }).build(); - await TestBed.configureTestingModule({ - imports: [RegistriesSubjectsComponent, OSFTestingModule, MockComponent(SubjectsComponent)], + const mockSubjects: SubjectModel[] = [ + { id: 'sub-1', name: 'Subject 1' }, + { id: 'sub-2', name: 'Subject 2' }, + ]; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [RegistriesSubjectsComponent, MockComponent(SubjectsComponent)], providers: [ - MockProvider(ActivatedRoute, mockActivatedRoute), + provideOSFCore(), provideMockStore({ signals: [ - { selector: RegistriesSelectors.getDraftRegistration, value: { providerId: 'prov-1' } }, { selector: SubjectsSelectors.getSelectedSubjects, value: [] }, - { selector: SubjectsSelectors.getSubjects, value: [] }, - { selector: SubjectsSelectors.getSearchedSubjects, value: [] }, - { selector: SubjectsSelectors.getSubjectsLoading, value: false }, - { selector: SubjectsSelectors.getSearchedSubjectsLoading, value: false }, { selector: SubjectsSelectors.areSelectedSubjectsLoading, value: false }, + { selector: RegistriesSelectors.getDraftRegistration, value: MOCK_DRAFT_REGISTRATION }, ], }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(RegistriesSubjectsComponent); component = fixture.componentInstance; - fixture.componentRef.setInput('control', new FormControl([])); - const mockActions = { - fetchSubjects: jest.fn().mockReturnValue(of({})), - fetchSelectedSubjects: jest.fn().mockReturnValue(of({})), - fetchChildrenSubjects: jest.fn().mockReturnValue(of({})), - updateResourceSubjects: jest.fn().mockReturnValue(of({})), - } as any; - Object.defineProperty(component, 'actions', { value: mockActions }); + fixture.componentRef.setInput('control', new FormControl(null, Validators.required)); + fixture.componentRef.setInput('draftId', 'draft-1'); fixture.detectChanges(); }); @@ -59,33 +60,54 @@ describe('RegistriesSubjectsComponent', () => { expect(component).toBeTruthy(); }); - it('should fetch subjects and selected subjects on init', () => { - const actions = (component as any).actions; - expect(actions.fetchSubjects).toHaveBeenCalledWith(ResourceType.Registration, 'prov-1'); - expect(actions.fetchSelectedSubjects).toHaveBeenCalledWith('draft-1', ResourceType.DraftRegistration); + it('should dispatch fetchSubjects and fetchSelectedSubjects on init', () => { + expect(store.dispatch).toHaveBeenCalledWith( + new FetchSubjects(ResourceType.Registration, MOCK_DRAFT_REGISTRATION.providerId) + ); + expect(store.dispatch).toHaveBeenCalledWith(new FetchSelectedSubjects('draft-1', ResourceType.DraftRegistration)); }); - it('should fetch children on demand', () => { - const actions = (component as any).actions; + it('should dispatch fetchChildrenSubjects on getSubjectChildren', () => { + (store.dispatch as jest.Mock).mockClear(); component.getSubjectChildren('parent-1'); - expect(actions.fetchChildrenSubjects).toHaveBeenCalledWith('parent-1'); + expect(store.dispatch).toHaveBeenCalledWith(new FetchChildrenSubjects('parent-1')); }); - it('should search subjects', () => { - const actions = (component as any).actions; - component.searchSubjects('term'); - expect(actions.fetchSubjects).toHaveBeenCalledWith(ResourceType.Registration, 'prov-1', 'term'); + it('should dispatch fetchSubjects with search term on searchSubjects', () => { + (store.dispatch as jest.Mock).mockClear(); + component.searchSubjects('biology'); + expect(store.dispatch).toHaveBeenCalledWith( + new FetchSubjects(ResourceType.Registration, MOCK_DRAFT_REGISTRATION.providerId, 'biology') + ); }); - it('should update selected subjects and control state', () => { - const actions = (component as any).actions; - const nextSubjects = [{ id: 's1' } as any]; - component.updateSelectedSubjects(nextSubjects); - expect(actions.updateResourceSubjects).toHaveBeenCalledWith( - 'draft-1', - ResourceType.DraftRegistration, - nextSubjects + it('should dispatch updateResourceSubjects and update control on updateSelectedSubjects', () => { + (store.dispatch as jest.Mock).mockClear(); + component.updateSelectedSubjects(mockSubjects); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateResourceSubjects('draft-1', ResourceType.DraftRegistration, mockSubjects) ); - expect(component.control().value).toEqual(nextSubjects); + expect(component.control().value).toEqual(mockSubjects); + expect(component.control().touched).toBe(true); + expect(component.control().dirty).toBe(true); + }); + + it('should mark control as touched and dirty on focusout', () => { + component.onFocusOut(); + expect(component.control().touched).toBe(true); + expect(component.control().dirty).toBe(true); + }); + + it('should have invalid control when value is null', () => { + component.control().markAsTouched(); + component.control().updateValueAndValidity(); + expect(component.control().valid).toBe(false); + expect(component.control().errors?.['required']).toBeTruthy(); + }); + + it('should have valid control when subjects are set', () => { + component.updateControlState(mockSubjects); + expect(component.control().valid).toBe(true); + expect(component.control().errors).toBeNull(); }); }); diff --git a/src/app/features/registries/components/registries-metadata-step/registries-subjects/registries-subjects.component.ts b/src/app/features/registries/components/registries-metadata-step/registries-subjects/registries-subjects.component.ts index 01915ab96..9e0f38db6 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-subjects/registries-subjects.component.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-subjects/registries-subjects.component.ts @@ -5,9 +5,8 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Card } from 'primeng/card'; import { Message } from 'primeng/message'; -import { ChangeDetectionStrategy, Component, effect, inject, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, effect, input, signal } from '@angular/core'; import { FormControl } from '@angular/forms'; -import { ActivatedRoute } from '@angular/router'; import { RegistriesSelectors } from '@osf/features/registries/store'; import { SubjectsComponent } from '@osf/shared/components/subjects/subjects.component'; @@ -31,8 +30,7 @@ import { SubjectModel } from '@shared/models/subject/subject.model'; }) export class RegistriesSubjectsComponent { control = input.required(); - private readonly route = inject(ActivatedRoute); - private readonly draftId = this.route.snapshot.params['id']; + draftId = input.required(); selectedSubjects = select(SubjectsSelectors.getSelectedSubjects); isSubjectsUpdating = select(SubjectsSelectors.areSelectedSubjectsLoading); @@ -47,14 +45,14 @@ export class RegistriesSubjectsComponent { readonly INPUT_VALIDATION_MESSAGES = INPUT_VALIDATION_MESSAGES; - private isLoaded = false; + private readonly isLoaded = signal(false); constructor() { effect(() => { - if (this.draftRegistration() && !this.isLoaded) { + if (this.draftRegistration() && !this.isLoaded()) { this.actions.fetchSubjects(ResourceType.Registration, this.draftRegistration()?.providerId); - this.actions.fetchSelectedSubjects(this.draftId, ResourceType.DraftRegistration); - this.isLoaded = true; + this.actions.fetchSelectedSubjects(this.draftId(), ResourceType.DraftRegistration); + this.isLoaded.set(true); } }); } @@ -69,23 +67,21 @@ export class RegistriesSubjectsComponent { updateSelectedSubjects(subjects: SubjectModel[]) { this.updateControlState(subjects); - this.actions.updateResourceSubjects(this.draftId, ResourceType.DraftRegistration, subjects); + this.actions.updateResourceSubjects(this.draftId(), ResourceType.DraftRegistration, subjects); } onFocusOut() { - if (this.control()) { - this.control().markAsTouched(); - this.control().markAsDirty(); - this.control().updateValueAndValidity(); - } + const control = this.control(); + control.markAsTouched(); + control.markAsDirty(); + control.updateValueAndValidity(); } updateControlState(value: SubjectModel[]) { - if (this.control()) { - this.control().setValue(value); - this.control().markAsTouched(); - this.control().markAsDirty(); - this.control().updateValueAndValidity(); - } + const control = this.control(); + control.setValue(value); + control.markAsTouched(); + control.markAsDirty(); + control.updateValueAndValidity(); } } diff --git a/src/app/features/registries/components/registries-metadata-step/registries-tags/registries-tags.component.spec.ts b/src/app/features/registries/components/registries-metadata-step/registries-tags/registries-tags.component.spec.ts index 4072f1ed6..914396af1 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-tags/registries-tags.component.spec.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-tags/registries-tags.component.spec.ts @@ -1,36 +1,34 @@ -import { of } from 'rxjs'; +import { Store } from '@ngxs/store'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; -import { RegistriesSelectors } from '@osf/features/registries/store'; +import { RegistriesSelectors, UpdateDraft } from '@osf/features/registries/store'; import { RegistriesTagsComponent } from './registries-tags.component'; -import { OSFTestingModule } from '@testing/osf.testing.module'; -import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; +import { provideOSFCore } from '@testing/osf.testing.provider'; import { provideMockStore } from '@testing/providers/store-provider.mock'; describe('RegistriesTagsComponent', () => { let component: RegistriesTagsComponent; let fixture: ComponentFixture; - let mockActivatedRoute: ReturnType; + let store: Store; - beforeEach(async () => { - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'someId' }).build(); - - await TestBed.configureTestingModule({ - imports: [RegistriesTagsComponent, OSFTestingModule], + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [RegistriesTagsComponent], providers: [ - { provide: ActivatedRoute, useValue: mockActivatedRoute }, + provideOSFCore(), provideMockStore({ signals: [{ selector: RegistriesSelectors.getSelectedTags, value: [] }], }), ], - }).compileComponents(); + }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(RegistriesTagsComponent); component = fixture.componentInstance; + fixture.componentRef.setInput('draftId', 'someId'); fixture.detectChanges(); }); @@ -44,11 +42,7 @@ describe('RegistriesTagsComponent', () => { }); it('should update tags on change', () => { - const mockActions = { - updateDraft: jest.fn().mockReturnValue(of({})), - } as any; - Object.defineProperty(component, 'actions', { value: mockActions }); component.onTagsChanged(['a', 'b']); - expect(mockActions.updateDraft).toHaveBeenCalledWith('someId', { tags: ['a', 'b'] }); + expect(store.dispatch).toHaveBeenCalledWith(new UpdateDraft('someId', { tags: ['a', 'b'] })); }); }); diff --git a/src/app/features/registries/components/registries-metadata-step/registries-tags/registries-tags.component.ts b/src/app/features/registries/components/registries-metadata-step/registries-tags/registries-tags.component.ts index 5c8c32cd1..dcba22a36 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-tags/registries-tags.component.ts +++ b/src/app/features/registries/components/registries-metadata-step/registries-tags/registries-tags.component.ts @@ -4,8 +4,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Card } from 'primeng/card'; -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; import { RegistriesSelectors, UpdateDraft } from '@osf/features/registries/store'; import { TagsInputComponent } from '@osf/shared/components/tags-input/tags-input.component'; @@ -18,15 +17,13 @@ import { TagsInputComponent } from '@osf/shared/components/tags-input/tags-input changeDetection: ChangeDetectionStrategy.OnPush, }) export class RegistriesTagsComponent { - private readonly route = inject(ActivatedRoute); - private readonly draftId = this.route.snapshot.params['id']; - selectedTags = select(RegistriesSelectors.getSelectedTags); + draftId = input.required(); + + actions = createDispatchMap({ updateDraft: UpdateDraft }); - actions = createDispatchMap({ - updateDraft: UpdateDraft, - }); + selectedTags = select(RegistriesSelectors.getSelectedTags); onTagsChanged(tags: string[]): void { - this.actions.updateDraft(this.draftId, { tags }); + this.actions.updateDraft(this.draftId(), { tags }); } } diff --git a/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.ts b/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.ts index 25350b20b..5fc736156 100644 --- a/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.ts +++ b/src/app/features/registries/components/select-components-dialog/select-components-dialog.component.ts @@ -7,7 +7,7 @@ import { Tree } from 'primeng/tree'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; -import { ProjectShortInfoModel } from '../../models'; +import { ProjectShortInfoModel } from '../../models/project-short-info.model'; @Component({ selector: 'osf-select-components-dialog', @@ -19,6 +19,7 @@ import { ProjectShortInfoModel } from '../../models'; export class SelectComponentsDialogComponent { readonly dialogRef = inject(DynamicDialogRef); readonly config = inject(DynamicDialogConfig); + selectedComponents: TreeNode[] = []; parent: ProjectShortInfoModel = this.config.data.parent; components: TreeNode[] = []; @@ -37,10 +38,14 @@ export class SelectComponentsDialogComponent { this.selectedComponents.push({ key: this.parent.id }); } + continue() { + const selectedComponentsSet = new Set([...this.selectedComponents.map((c) => c.key!), this.parent.id]); + this.dialogRef.close([...selectedComponentsSet]); + } + private mapProjectToTreeNode = (project: ProjectShortInfoModel): TreeNode => { - this.selectedComponents.push({ - key: project.id, - }); + this.selectedComponents.push({ key: project.id }); + return { label: project.title, data: project.id, @@ -49,9 +54,4 @@ export class SelectComponentsDialogComponent { children: project.children?.map(this.mapProjectToTreeNode) ?? [], }; }; - - continue() { - const selectedComponentsSet = new Set([...this.selectedComponents.map((c) => c.key!), this.parent.id]); - this.dialogRef.close([...selectedComponentsSet]); - } } diff --git a/src/app/features/registries/models/index.ts b/src/app/features/registries/models/index.ts deleted file mode 100644 index 101e1aeac..000000000 --- a/src/app/features/registries/models/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './project-short-info.model'; diff --git a/src/app/features/registries/store/handlers/projects.handlers.ts b/src/app/features/registries/store/handlers/projects.handlers.ts index 9738d8442..88562a02a 100644 --- a/src/app/features/registries/store/handlers/projects.handlers.ts +++ b/src/app/features/registries/store/handlers/projects.handlers.ts @@ -5,7 +5,7 @@ import { inject, Injectable } from '@angular/core'; import { handleSectionError } from '@osf/shared/helpers/state-error.handler'; import { ProjectsService } from '@osf/shared/services/projects.service'; -import { ProjectShortInfoModel } from '../../models'; +import { ProjectShortInfoModel } from '../../models/project-short-info.model'; import { REGISTRIES_STATE_DEFAULTS, RegistriesStateModel } from '../registries.model'; @Injectable() diff --git a/src/app/features/registries/store/registries.model.ts b/src/app/features/registries/store/registries.model.ts index f83ee5291..4542aaa96 100644 --- a/src/app/features/registries/store/registries.model.ts +++ b/src/app/features/registries/store/registries.model.ts @@ -11,7 +11,7 @@ import { ResourceModel } from '@osf/shared/models/search/resource.model'; import { AsyncStateModel } from '@osf/shared/models/store/async-state.model'; import { AsyncStateWithTotalCount } from '@osf/shared/models/store/async-state-with-total-count.model'; -import { ProjectShortInfoModel } from '../models'; +import { ProjectShortInfoModel } from '../models/project-short-info.model'; export interface RegistriesStateModel { providerSchemas: AsyncStateModel; diff --git a/src/app/features/registries/store/registries.selectors.ts b/src/app/features/registries/store/registries.selectors.ts index 3335b9191..e75242bf2 100644 --- a/src/app/features/registries/store/registries.selectors.ts +++ b/src/app/features/registries/store/registries.selectors.ts @@ -1,5 +1,6 @@ import { Selector } from '@ngxs/store'; +import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; import { FileModel } from '@osf/shared/models/files/file.model'; import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; import { LicenseModel } from '@osf/shared/models/license/license.model'; @@ -11,7 +12,7 @@ import { RegistrationCard } from '@osf/shared/models/registration/registration-c import { SchemaResponse } from '@osf/shared/models/registration/schema-response.model'; import { ResourceModel } from '@osf/shared/models/search/resource.model'; -import { ProjectShortInfoModel } from '../models'; +import { ProjectShortInfoModel } from '../models/project-short-info.model'; import { RegistriesStateModel } from './registries.model'; import { RegistriesState } from './registries.state'; @@ -52,6 +53,11 @@ export class RegistriesSelectors { return state.draftRegistration.data; } + @Selector([RegistriesState]) + static hasDraftAdminAccess(state: RegistriesStateModel): boolean { + return state.draftRegistration.data?.currentUserPermissions?.includes(UserPermissions.Admin) || false; + } + @Selector([RegistriesState]) static getRegistrationLoading(state: RegistriesStateModel): boolean { return state.draftRegistration.isLoading || state.draftRegistration.isSubmitting || state.pagesSchema.isLoading; diff --git a/src/app/features/registries/store/registries.state.ts b/src/app/features/registries/store/registries.state.ts index 6a8ae7120..d24602bbf 100644 --- a/src/app/features/registries/store/registries.state.ts +++ b/src/app/features/registries/store/registries.state.ts @@ -54,11 +54,11 @@ import { REGISTRIES_STATE_DEFAULTS, RegistriesStateModel } from './registries.mo }) @Injectable() export class RegistriesState { - searchService = inject(GlobalSearchService); - registriesService = inject(RegistriesService); private readonly environment = inject(ENVIRONMENT); private readonly store = inject(Store); + searchService = inject(GlobalSearchService); + registriesService = inject(RegistriesService); providersHandler = inject(ProvidersHandlers); projectsHandler = inject(ProjectsHandlers); licensesHandler = inject(LicensesHandlers); @@ -238,7 +238,7 @@ export class RegistriesState { }, }); }), - catchError((error) => handleSectionError(ctx, 'draftRegistration', error)) + catchError((error) => handleSectionError(ctx, 'registration', error)) ); }