diff --git a/src/app/features/collections/services/add-to-collection.service.spec.ts b/src/app/features/collections/services/add-to-collection.service.spec.ts new file mode 100644 index 000000000..f12e874da --- /dev/null +++ b/src/app/features/collections/services/add-to-collection.service.spec.ts @@ -0,0 +1,114 @@ +import { MockProvider } from 'ng-mocks'; + +import { firstValueFrom, of } from 'rxjs'; + +import { TestBed } from '@angular/core/testing'; + +import { CollectionsMapper } from '@osf/shared/mappers/collections'; +import { ReviewActionsMapper } from '@osf/shared/mappers/review-actions.mapper'; +import { CollectionSubmissionPayload } from '@osf/shared/models/collections/collection-submission-payload.model'; +import { CollectionSubmissionPayloadJsonApi } from '@osf/shared/models/collections/collection-submission-payload-json-api.model'; +import { ReviewActionPayloadJsonApi } from '@osf/shared/models/review-action/review-action-payload-json-api.model'; +import { JsonApiService } from '@osf/shared/services/json-api.service'; + +import { MOCK_API_URL, provideMockEnvironment } from '@testing/providers/environment.provider.mock'; +import { JsonApiServiceMock, JsonApiServiceMockType } from '@testing/providers/json-api.service.mock'; + +import { RemoveCollectionSubmissionPayload } from '../models/remove-collection-submission-payload.model'; + +import { AddToCollectionService } from './add-to-collection.service'; + +describe('AddToCollectionService', () => { + let service: AddToCollectionService; + let jsonApiService: JsonApiServiceMockType; + + const submissionPayload: CollectionSubmissionPayload = { + collectionId: 'collection-1', + projectId: 'project-1', + userId: 'user-1', + }; + + const removePayload: RemoveCollectionSubmissionPayload = { + projectId: 'project-1', + collectionId: 'collection-1', + comment: 'Removing from collection', + }; + + function setup() { + jsonApiService = JsonApiServiceMock.simple(); + + TestBed.configureTestingModule({ + providers: [AddToCollectionService, MockProvider(JsonApiService, jsonApiService), provideMockEnvironment()], + }); + + service = TestBed.inject(AddToCollectionService); + } + + it('should expose apiUrl from environment', () => { + setup(); + + expect(service.apiUrl).toBe(MOCK_API_URL); + }); + + it('createCollectionSubmission should map payload and post to collection submissions', async () => { + setup(); + const mappedPayload = { + data: { + type: 'collection-submissions', + attributes: { guid: 'project-1' }, + relationships: { + collection: { data: { id: 'collection-1', type: 'collections' } }, + creator: { data: { type: 'users', id: 'user-1' } }, + }, + }, + } as CollectionSubmissionPayloadJsonApi; + const mapperSpy = vi.spyOn(CollectionsMapper, 'toCollectionSubmissionRequest').mockReturnValue(mappedPayload); + jsonApiService.post.mockReturnValue(of(undefined)); + + const response = await firstValueFrom(service.createCollectionSubmission(submissionPayload)); + + expect(mapperSpy).toHaveBeenCalledWith(submissionPayload); + expect(jsonApiService.post).toHaveBeenCalledWith( + `${MOCK_API_URL}/collections/${submissionPayload.collectionId}/collection_submissions/`, + mappedPayload + ); + expect(response).toBeUndefined(); + }); + + it('removeCollectionSubmission should map review action and post to collection submission actions', async () => { + setup(); + const mappedParams = { + data: { + type: 'collection_submission_actions', + attributes: { + trigger: 'remove', + comment: removePayload.comment, + }, + relationships: { + target: { + data: { + type: 'collection-submissions', + id: `${removePayload.projectId}-${removePayload.collectionId}`, + }, + }, + }, + }, + } as ReviewActionPayloadJsonApi; + const mapperSpy = vi.spyOn(ReviewActionsMapper, 'toReviewActionPayloadJsonApi').mockReturnValue(mappedParams); + jsonApiService.post.mockReturnValue(of(undefined)); + + const response = await firstValueFrom(service.removeCollectionSubmission(removePayload)); + + expect(mapperSpy).toHaveBeenCalledWith( + { + action: 'remove', + targetId: `${removePayload.projectId}-${removePayload.collectionId}`, + comment: removePayload.comment, + }, + 'collection_submission_actions', + 'collection-submissions' + ); + expect(jsonApiService.post).toHaveBeenCalledWith(`${MOCK_API_URL}/collection_submission_actions/`, mappedParams); + expect(response).toBeUndefined(); + }); +}); diff --git a/src/app/features/collections/services/add-to-collection.service.ts b/src/app/features/collections/services/add-to-collection.service.ts index c2e8a1c1b..8f786bb7f 100644 --- a/src/app/features/collections/services/add-to-collection.service.ts +++ b/src/app/features/collections/services/add-to-collection.service.ts @@ -1,16 +1,13 @@ -import { map, Observable } from 'rxjs'; +import { Observable } from 'rxjs'; import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { CollectionsMapper } from '@osf/shared/mappers/collections'; -import { LicensesMapper } from '@osf/shared/mappers/licenses.mapper'; import { ReviewActionsMapper } from '@osf/shared/mappers/review-actions.mapper'; import { ReviewActionPayload } from '@osf/shared/models/review-action/review-action-payload.model'; import { JsonApiService } from '@osf/shared/services/json-api.service'; import { CollectionSubmissionPayload } from '@shared/models/collections/collection-submission-payload.model'; -import { LicenseModel } from '@shared/models/license/license.model'; -import { LicensesResponseJsonApi } from '@shared/models/license/licenses-json-api.model'; import { RemoveCollectionSubmissionPayload } from '../models/remove-collection-submission-payload.model'; @@ -25,15 +22,6 @@ export class AddToCollectionService { return `${this.environment.apiDomainUrl}/v2`; } - fetchCollectionLicenses(providerId: string): Observable { - return this.jsonApiService - .get(`${this.apiUrl}/providers/collections/${providerId}/licenses/`, { - 'page[size]': 100, - sort: 'name', - }) - .pipe(map((licenses) => LicensesMapper.fromLicensesResponse(licenses))); - } - createCollectionSubmission(payload: CollectionSubmissionPayload): Observable { const collectionId = payload.collectionId; const metadata = CollectionsMapper.toCollectionSubmissionRequest(payload); diff --git a/src/app/features/collections/store/add-to-collection/add-to-collection.state.ts b/src/app/features/collections/store/add-to-collection/add-to-collection.state.ts index 1e1f230f0..4aa831cac 100644 --- a/src/app/features/collections/store/add-to-collection/add-to-collection.state.ts +++ b/src/app/features/collections/store/add-to-collection/add-to-collection.state.ts @@ -6,8 +6,10 @@ import { catchError } from 'rxjs/operators'; import { inject, Injectable } from '@angular/core'; import { AddToCollectionService } from '@osf/features/collections/services/add-to-collection.service'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { handleSectionError } from '@osf/shared/helpers/state-error.handler'; import { CollectionsService } from '@osf/shared/services/collections.service'; +import { LicensesService } from '@osf/shared/services/licenses.service'; import { ClearAddToCollectionState, @@ -26,6 +28,7 @@ import { ADD_TO_COLLECTION_DEFAULTS, AddToCollectionStateModel } from './add-to- export class AddToCollectionState { addToCollectionService = inject(AddToCollectionService); collectionsService = inject(CollectionsService); + licensesService = inject(LicensesService); @Action(GetCollectionLicenses) getCollectionLicenses(ctx: StateContext, action: GetCollectionLicenses) { @@ -37,7 +40,7 @@ export class AddToCollectionState { }, }); - return this.addToCollectionService.fetchCollectionLicenses(action.providerId).pipe( + return this.licensesService.getLicenses(ResourceType.Collection, action.providerId).pipe( tap((licenses) => { ctx.patchState({ collectionLicenses: { diff --git a/src/app/features/metadata/dialogs/license-dialog/license-dialog.component.html b/src/app/features/metadata/dialogs/license-dialog/license-dialog.component.html index 019867cbc..342ca8642 100644 --- a/src/app/features/metadata/dialogs/license-dialog/license-dialog.component.html +++ b/src/app/features/metadata/dialogs/license-dialog/license-dialog.component.html @@ -1,32 +1,22 @@
@if (licensesLoading()) { - + } @else { -
-

{{ 'project.metadata.license.dialog.chooseLicense.label' | translate }}

+

{{ 'project.metadata.license.dialog.chooseLicense.label' | translate }}

- -
+ }
- +
diff --git a/src/app/features/metadata/dialogs/license-dialog/license-dialog.component.spec.ts b/src/app/features/metadata/dialogs/license-dialog/license-dialog.component.spec.ts index 63f7e51c4..3792f7080 100644 --- a/src/app/features/metadata/dialogs/license-dialog/license-dialog.component.spec.ts +++ b/src/app/features/metadata/dialogs/license-dialog/license-dialog.component.spec.ts @@ -1,15 +1,22 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { signal, WritableSignal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { LicenseComponent } from '@osf/shared/components/license/license.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; -import { LicensesSelectors } from '@shared/stores/licenses'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { LicenseModel, LicenseOptions } from '@osf/shared/models/license/license.model'; +import { FetchLicenses, LicensesSelectors } from '@osf/shared/stores/licenses'; import { MOCK_LICENSE } from '@testing/mocks/license.mock'; +import { MOCK_PROJECT_METADATA } from '@testing/mocks/project-metadata.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; +import { provideDynamicDialogRefMock } from '@testing/providers/dynamic-dialog-ref.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; import { LicenseDialogComponent } from './license-dialog.component'; @@ -17,121 +24,195 @@ import { LicenseDialogComponent } from './license-dialog.component'; describe('LicenseDialogComponent', () => { let component: LicenseDialogComponent; let fixture: ComponentFixture; + let store: Store; + let dialogRef: DynamicDialogRef; + let licensesLoadingSignal: WritableSignal; + + const licenseWithoutFields: LicenseModel = { + ...MOCK_LICENSE, + id: 'license-simple', + name: 'MIT', + requiredFields: [], + }; + + const mockLicenseOptions: LicenseOptions = { + copyrightHolders: 'John Doe', + year: '2023', + }; + + interface SetupOverrides { + dialogData?: { + resourceType?: ResourceType; + providerId?: string; + metadata?: typeof MOCK_PROJECT_METADATA | null; + }; + licenses?: LicenseModel[]; + licensesLoading?: boolean; + } + + function setup(overrides: SetupOverrides = {}) { + licensesLoadingSignal = signal(overrides.licensesLoading ?? false); - beforeEach(() => { TestBed.configureTestingModule({ imports: [LicenseDialogComponent, ...MockComponents(LoadingSpinnerComponent, LicenseComponent)], providers: [ provideOSFCore(), - MockProvider(DynamicDialogRef), - MockProvider(DynamicDialogConfig), + provideDynamicDialogRefMock(), + MockProvider(DynamicDialogConfig, { data: overrides.dialogData ?? {} }), provideMockStore({ signals: [ - { selector: LicensesSelectors.getLicenses, value: [MOCK_LICENSE] }, - { selector: LicensesSelectors.getLoading, value: false }, + { + selector: LicensesSelectors.getLicenses, + value: overrides.licenses ?? [MOCK_LICENSE, licenseWithoutFields], + }, + { selector: LicensesSelectors.getLoading, value: licensesLoadingSignal }, ], }), ], }); + store = TestBed.inject(Store); + dialogRef = TestBed.inject(DynamicDialogRef); fixture = TestBed.createComponent(LicenseDialogComponent); component = fixture.componentInstance; + fixture.detectChanges(); + } + + it('should dispatch FetchLicenses on init with defaults', () => { + setup(); + + expect(store.dispatch).toHaveBeenCalledWith(new FetchLicenses(ResourceType.Project, undefined)); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('should dispatch FetchLicenses with dialog resource type and provider', () => { + setup({ + dialogData: { + resourceType: ResourceType.Registration, + providerId: 'provider-1', + }, + }); + + expect(store.dispatch).toHaveBeenCalledWith(new FetchLicenses(ResourceType.Registration, 'provider-1')); }); - it('should handle license selection', () => { - component.onSelectLicense(MOCK_LICENSE); + it('should initialize selected license and options from metadata', () => { + setup({ + dialogData: { + metadata: { + ...MOCK_PROJECT_METADATA, + license: MOCK_LICENSE, + }, + }, + }); expect(component.selectedLicenseId()).toBe(MOCK_LICENSE.id); + expect(component.initialLicenseOptions).toEqual({ + copyrightHolders: 'Test Author, Test University', + year: '2024', + }); }); - it('should handle license creation with non-existent license', () => { - const dialogRef = TestBed.inject(DynamicDialogRef); + it('should close dialog with license options when saving a valid required-fields license', () => { + setup(); const closeSpy = vi.spyOn(dialogRef, 'close'); + component.selectedLicenseId.set(MOCK_LICENSE.id); + component.licenseState.set({ + licenseOptions: mockLicenseOptions, + formInvalid: false, + }); - const createEvent = { - id: 'non-existent-license', - licenseOptions: { copyrightHolders: 'John Doe', year: '2023' }, - }; - - component.onCreateLicense(createEvent); + expect(component.isSaveDisabled()).toBe(false); + component.save(); - expect(closeSpy).not.toHaveBeenCalled(); - expect(component.isSubmitting()).toBe(false); + expect(closeSpy).toHaveBeenCalledWith({ + licenseId: MOCK_LICENSE.id, + licenseOptions: mockLicenseOptions, + }); }); - it('should handle save with license that has required fields', () => { + it('should not save when required fields form is invalid', () => { + setup(); + const closeSpy = vi.spyOn(dialogRef, 'close'); component.selectedLicenseId.set(MOCK_LICENSE.id); + component.licenseState.set({ + licenseOptions: { copyrightHolders: '', year: '' }, + formInvalid: true, + }); - const mockLicenseComponent = { - selectedLicense: () => MOCK_LICENSE, - licenseForm: { invalid: false }, - saveLicense: vi.fn(), - }; + expect(component.isSaveDisabled()).toBe(true); + component.save(); - Object.defineProperty(component, 'licenseComponent', { - get: () => () => mockLicenseComponent, - }); + expect(closeSpy).not.toHaveBeenCalled(); + }); + + it('should close dialog when saving license without required fields', () => { + setup(); + const closeSpy = vi.spyOn(dialogRef, 'close'); + component.selectedLicenseId.set(licenseWithoutFields.id); + expect(component.isSaveDisabled()).toBe(false); component.save(); - expect(mockLicenseComponent.saveLicense).toHaveBeenCalled(); - expect(component.isSubmitting()).toBe(true); + expect(closeSpy).toHaveBeenCalledWith({ + licenseId: licenseWithoutFields.id, + licenseOptions: undefined, + }); }); it('should not save when no license is selected', () => { - const dialogRef = TestBed.inject(DynamicDialogRef); + setup(); const closeSpy = vi.spyOn(dialogRef, 'close'); - component.selectedLicenseId.set(null); + expect(component.isSaveDisabled()).toBe(true); component.save(); expect(closeSpy).not.toHaveBeenCalled(); }); it('should not save when selected license is not found', () => { - const dialogRef = TestBed.inject(DynamicDialogRef); + setup(); const closeSpy = vi.spyOn(dialogRef, 'close'); - component.selectedLicenseId.set('non-existent-license'); + expect(component.isSaveDisabled()).toBe(true); component.save(); expect(closeSpy).not.toHaveBeenCalled(); }); - it('should handle cancel', () => { - const dialogRef = TestBed.inject(DynamicDialogRef); + it('should close dialog on cancel', () => { + setup(); const closeSpy = vi.spyOn(dialogRef, 'close'); - const mockLicenseComponent = { - cancel: vi.fn(), - }; - - Object.defineProperty(component, 'licenseComponent', { - get: () => () => mockLicenseComponent, - }); - component.cancel(); - expect(mockLicenseComponent.cancel).toHaveBeenCalled(); expect(closeSpy).toHaveBeenCalled(); }); - it('should handle cancel when license component is not available', () => { - const dialogRef = TestBed.inject(DynamicDialogRef); - const closeSpy = vi.spyOn(dialogRef, 'close'); + it('should disable save while licenses are loading', () => { + setup({ licensesLoading: true }); - Object.defineProperty(component, 'licenseComponent', { - get: () => () => null, - }); + expect(component.isSaveDisabled()).toBe(true); + }); - component.cancel(); + it('should disable save when license and options are unchanged', () => { + setup({ + dialogData: { + metadata: { + ...MOCK_PROJECT_METADATA, + license: MOCK_LICENSE, + }, + }, + }); + component.licenseState.set({ + licenseOptions: { + copyrightHolders: 'Test Author, Test University', + year: '2024', + }, + formInvalid: false, + }); - expect(closeSpy).toHaveBeenCalled(); + expect(component.isSaveDisabled()).toBe(true); }); }); diff --git a/src/app/features/metadata/dialogs/license-dialog/license-dialog.component.ts b/src/app/features/metadata/dialogs/license-dialog/license-dialog.component.ts index 0afd8ca27..8f8790bd2 100644 --- a/src/app/features/metadata/dialogs/license-dialog/license-dialog.component.ts +++ b/src/app/features/metadata/dialogs/license-dialog/license-dialog.component.ts @@ -5,13 +5,16 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { ChangeDetectionStrategy, Component, inject, OnInit, signal, viewChild } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core'; -import { MetadataModel } from '@osf/features/metadata/models'; import { LicenseComponent } from '@osf/shared/components/license/license.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; -import { LicenseModel, LicenseOptions } from '@osf/shared/models/license/license.model'; -import { LicensesSelectors, LoadAllLicenses } from '@osf/shared/stores/licenses'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { LicenseOptions, LicenseState } from '@osf/shared/models/license/license.model'; +import { FetchLicenses, LicensesSelectors } from '@osf/shared/stores/licenses'; + +import { MetadataModel } from '../../models'; +import { LicenseDialogData, LicenseDialogResult } from '../../models/license-dialog.model'; @Component({ selector: 'osf-license-dialog', @@ -20,78 +23,86 @@ import { LicensesSelectors, LoadAllLicenses } from '@osf/shared/stores/licenses' changeDetection: ChangeDetectionStrategy.OnPush, }) export class LicenseDialogComponent implements OnInit { - dialogRef = inject(DynamicDialogRef); - config = inject(DynamicDialogConfig); + readonly dialogRef = inject(DynamicDialogRef); + readonly config = inject(DynamicDialogConfig); + + readonly actions = createDispatchMap({ fetchLicenses: FetchLicenses }); + + readonly licenses = select(LicensesSelectors.getLicenses); + readonly licensesLoading = select(LicensesSelectors.getLoading); - actions = createDispatchMap({ loadLicenses: LoadAllLicenses }); + private readonly data = this.config.data ?? {}; + private readonly initialLicenseId = this.data.metadata?.license?.id || null; - licenses = select(LicensesSelectors.getLicenses); - licensesLoading = select(LicensesSelectors.getLoading); + readonly initialLicenseOptions = this.resolveLicenseOptions(this.data.metadata); + readonly selectedLicenseId = signal(this.initialLicenseId); + readonly licenseState = signal(null); - selectedLicenseId = signal(null); - selectedLicenseOptions = signal(null); - metadata: MetadataModel | null = null; - isSubmitting = signal(false); + readonly selectedLicense = computed( + () => this.licenses().find((license) => license.id === this.selectedLicenseId()) ?? null + ); - licenseComponent = viewChild('licenseComponent'); + readonly isUnchanged = computed(() => { + const license = this.selectedLicense(); + if (!license || license.id !== this.initialLicenseId) { + return false; + } + + if (!license.requiredFields.length) { + return true; + } + + const initial = this.initialLicenseOptions; + const current = this.licenseState()?.licenseOptions; - get isInvalid(): boolean { return ( - !!this.licenseComponent()?.selectedLicense()?.requiredFields?.length && - !!this.licenseComponent()?.licenseForm.invalid + !!initial && !!current && initial.year === current.year && initial.copyrightHolders === current.copyrightHolders ); - } + }); - ngOnInit(): void { - this.actions.loadLicenses(); - this.metadata = this.config.data?.metadata || null; - if (this.metadata?.license) { - this.selectedLicenseId.set(this.metadata.license.id || null); - if (this.metadata.nodeLicense) { - this.selectedLicenseOptions.set({ - copyrightHolders: this.metadata.nodeLicense.copyrightHolders?.join(', ') || '', - year: this.metadata.nodeLicense.year || new Date().getFullYear().toString(), - }); - } + readonly isSaveDisabled = computed(() => { + const license = this.selectedLicense(); + if (this.licensesLoading() || !license) { + return true; } - } - - onSelectLicense(license: LicenseModel): void { - this.selectedLicenseId.set(license.id); - } - onCreateLicense(event: { id: string; licenseOptions: LicenseOptions }): void { - const selectedLicense = this.licenses().find((license) => license.id === event.id); - if (selectedLicense) { - this.dialogRef.close({ - licenseId: selectedLicense.id, - licenseOptions: event.licenseOptions, - }); + if (license.requiredFields.length && (this.licenseState()?.formInvalid ?? true)) { + return true; } - this.isSubmitting.set(false); + return this.isUnchanged(); + }); + + ngOnInit(): void { + this.actions.fetchLicenses(this.data.resourceType ?? ResourceType.Project, this.data.providerId); } save(): void { - const selectedLicenseId = this.selectedLicenseId(); - if (!selectedLicenseId) return; - - const selectedLicense = this.licenses().find((license) => license.id === selectedLicenseId); - if (!selectedLicense) return; - - this.isSubmitting.set(true); - - if (selectedLicense.requiredFields?.length) { - this.licenseComponent()?.saveLicense(); - } else { - this.dialogRef.close({ - licenseId: selectedLicense.id, - }); + const license = this.selectedLicense(); + if (!license || this.isSaveDisabled()) { + return; } + + const result: LicenseDialogResult = { + licenseId: license.id, + licenseOptions: license.requiredFields.length ? this.licenseState()?.licenseOptions : undefined, + }; + this.dialogRef.close(result); } cancel(): void { - this.licenseComponent()?.cancel(); this.dialogRef.close(); } + + private resolveLicenseOptions(metadata?: MetadataModel | null): LicenseOptions | null { + const nodeLicense = metadata?.nodeLicense; + if (!nodeLicense) { + return null; + } + + return { + copyrightHolders: nodeLicense.copyrightHolders?.join(', ') ?? '', + year: nodeLicense.year ?? '', + }; + } } diff --git a/src/app/features/metadata/metadata.component.spec.ts b/src/app/features/metadata/metadata.component.spec.ts index 1f6cfb69d..e26ff10b6 100644 --- a/src/app/features/metadata/metadata.component.spec.ts +++ b/src/app/features/metadata/metadata.component.spec.ts @@ -1,5 +1,9 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; +import { of } from 'rxjs'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; @@ -9,19 +13,45 @@ import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header 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 { SignpostingService } from '@osf/shared/services/signposting.service'; import { ToastService } from '@osf/shared/services/toast.service'; +import { CollectionsSelectors, GetProjectSubmissions } from '@osf/shared/stores/collections'; +import { + ContributorsSelectors, + GetBibliographicContributors, + LoadMoreBibliographicContributors, +} from '@osf/shared/stores/contributors'; +import { FetchResourceInstitutions, InstitutionsSelectors } from '@osf/shared/stores/institutions'; import { RegistrationProviderSelectors } from '@osf/shared/stores/registration-provider'; - +import { + FetchChildrenSubjects, + FetchSelectedSubjects, + FetchSubjects, + SubjectsSelectors, + UpdateResourceSubjects, +} from '@osf/shared/stores/subjects'; + +import { MOCK_LICENSE } from '@testing/mocks/license.mock'; import { MOCK_PROJECT_METADATA } from '@testing/mocks/project-metadata.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { CustomConfirmationServiceMockBuilder } from '@testing/providers/custom-confirmation-provider.mock'; -import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; +import { + CustomDialogServiceMock, + CustomDialogServiceMockBuilder, +} from '@testing/providers/custom-dialog-provider.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; -import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; +import { SignpostingServiceMock } from '@testing/providers/signposting-provider.mock'; +import { + BaseSetupOverrides, + mergeSignalOverrides, + provideMockStore, + SignalOverride, +} from '@testing/providers/store-provider.mock'; import { ToastServiceMockBuilder } from '@testing/providers/toast-provider.mock'; import { MetadataAffiliatedInstitutionsComponent } from './components/metadata-affiliated-institutions/metadata-affiliated-institutions.component'; +import { MetadataCollectionsComponent } from './components/metadata-collections/metadata-collections.component'; import { MetadataContributorsComponent } from './components/metadata-contributors/metadata-contributors.component'; import { MetadataDateInfoComponent } from './components/metadata-date-info/metadata-date-info.component'; import { MetadataDescriptionComponent } from './components/metadata-description/metadata-description.component'; @@ -29,59 +59,107 @@ import { MetadataFundingComponent } from './components/metadata-funding/metadata import { MetadataLicenseComponent } from './components/metadata-license/metadata-license.component'; import { MetadataPublicationDoiComponent } from './components/metadata-publication-doi/metadata-publication-doi.component'; import { MetadataRegistrationDoiComponent } from './components/metadata-registration-doi/metadata-registration-doi.component'; +import { MetadataRegistryInfoComponent } from './components/metadata-registry-info/metadata-registry-info.component'; import { MetadataResourceInformationComponent } from './components/metadata-resource-information/metadata-resource-information.component'; import { MetadataSubjectsComponent } from './components/metadata-subjects/metadata-subjects.component'; import { MetadataTagsComponent } from './components/metadata-tags/metadata-tags.component'; import { MetadataTitleComponent } from './components/metadata-title/metadata-title.component'; +import { AffiliatedInstitutionsDialogComponent } from './dialogs/affiliated-institutions-dialog/affiliated-institutions-dialog.component'; +import { ContributorsDialogComponent } from './dialogs/contributors-dialog/contributors-dialog.component'; +import { DescriptionDialogComponent } from './dialogs/description-dialog/description-dialog.component'; +import { EditTitleDialogComponent } from './dialogs/edit-title-dialog/edit-title-dialog.component'; +import { FundingDialogComponent } from './dialogs/funding-dialog/funding-dialog.component'; +import { LicenseDialogComponent } from './dialogs/license-dialog/license-dialog.component'; +import { PublicationDoiDialogComponent } from './dialogs/publication-doi-dialog/publication-doi-dialog.component'; +import { ResourceInformationDialogComponent } from './dialogs/resource-information-dialog/resource-information-dialog.component'; +import { ResourceInfoTooltipComponent } from './dialogs/resource-tooltip-info/resource-tooltip-info.component'; import { MetadataComponent } from './metadata.component'; -import { MetadataSelectors } from './store'; +import { + GetCedarMetadataRecords, + GetCedarMetadataTemplates, + GetCustomItemMetadata, + GetResourceMetadata, + MetadataSelectors, + UpdateResourceDetails, + UpdateResourceLicense, +} from './store'; describe('MetadataComponent', () => { let component: MetadataComponent; let fixture: ComponentFixture; - let activatedRouteMock: ReturnType; + let store: Store; let routerMock: ReturnType; let customDialogServiceMock: ReturnType; let toastServiceMock: ReturnType; let customConfirmationServiceMock: ReturnType; + let signpostingServiceMock: ReturnType; - const mockMetadata = MOCK_PROJECT_METADATA; - const mockResourceId = 'test-resource-id'; + const defaultResourceId = 'test-resource-id'; + const mockMetadata = { + ...MOCK_PROJECT_METADATA, + license: MOCK_LICENSE, + }; + + interface SetupOverrides extends BaseSetupOverrides { + resourceType?: ResourceType; + detectChanges?: boolean; + } - function setup(selectorOverrides?: SignalOverride[]) { - activatedRouteMock = ActivatedRouteMockBuilder.create() - .withId(mockResourceId) - .withData({ resourceType: ResourceType.Project }) + function setup(overrides: SetupOverrides = {}) { + const resourceType = overrides.resourceType ?? ResourceType.Project; + const resourceId = overrides.routeParams?.['id'] ?? defaultResourceId; + + const activatedRouteMock = ActivatedRouteMockBuilder.create() + .withParentRoute({ + snapshot: { + data: { resourceType }, + }, + parent: { + snapshot: { + params: { id: resourceId }, + }, + }, + } as never) .build(); const defaultSignals: SignalOverride[] = [ { selector: MetadataSelectors.getResourceMetadata, value: mockMetadata }, { selector: MetadataSelectors.getLoading, value: false }, { selector: MetadataSelectors.getSubmitting, value: false }, + { selector: MetadataSelectors.getCustomItemMetadata, value: null }, { selector: MetadataSelectors.getCedarRecords, value: [] }, { selector: MetadataSelectors.getCedarTemplates, value: null }, + { selector: MetadataSelectors.hasWriteAccess, value: true }, + { selector: MetadataSelectors.hasAdminAccess, value: true }, + { selector: ContributorsSelectors.getBibliographicContributors, value: [] }, + { selector: ContributorsSelectors.isBibliographicContributorsLoading, value: false }, + { selector: ContributorsSelectors.hasMoreBibliographicContributors, value: false }, + { selector: SubjectsSelectors.getSelectedSubjects, value: [] }, + { selector: SubjectsSelectors.areSelectedSubjectsLoading, value: false }, + { selector: InstitutionsSelectors.getResourceInstitutions, value: [] }, + { selector: InstitutionsSelectors.areResourceInstitutionsLoading, value: false }, + { selector: InstitutionsSelectors.areResourceInstitutionsSubmitting, value: false }, + { selector: CollectionsSelectors.getCurrentProjectSubmissions, value: [] }, + { selector: CollectionsSelectors.getCurrentProjectSubmissionsLoading, value: false }, { selector: RegistrationProviderSelectors.getBrandedProvider, value: null }, { selector: UserSelectors.isProjectReadOnly, value: false }, ]; - const signals = mergeSignalOverrides(defaultSignals, selectorOverrides); - - Object.defineProperty(activatedRouteMock, 'parent', { - value: { - snapshot: { - data: { resourceType: ResourceType.Project }, - }, - }, - writable: true, - configurable: true, - }); routerMock = RouterMockBuilder.create().build(); - - customDialogServiceMock = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); - + customDialogServiceMock = CustomDialogServiceMockBuilder.create() + .withOpen( + vi.fn().mockReturnValue({ + onClose: { + pipe: vi.fn().mockReturnValue({ subscribe: vi.fn() }), + subscribe: vi.fn(), + }, + close: vi.fn(), + }) + ) + .build(); toastServiceMock = ToastServiceMockBuilder.create().build(); - customConfirmationServiceMock = CustomConfirmationServiceMockBuilder.create().build(); + signpostingServiceMock = SignpostingServiceMock.simple(); TestBed.configureTestingModule({ imports: [ @@ -100,7 +178,9 @@ describe('MetadataComponent', () => { MetadataDateInfoComponent, MetadataTagsComponent, MetadataTitleComponent, - MetadataRegistrationDoiComponent + MetadataRegistrationDoiComponent, + MetadataCollectionsComponent, + MetadataRegistryInfoComponent ), ], providers: [ @@ -110,166 +190,245 @@ describe('MetadataComponent', () => { MockProvider(CustomDialogService, customDialogServiceMock), MockProvider(ToastService, toastServiceMock), MockProvider(CustomConfirmationService, customConfirmationServiceMock), + MockProvider(SignpostingService, signpostingServiceMock), provideMockStore({ - signals: signals, + signals: mergeSignalOverrides(defaultSignals, overrides.selectorOverrides), }), ], }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(MetadataComponent); component = fixture.componentInstance; + + if (overrides.detectChanges !== false) { + fixture.detectChanges(); + } } - it('should create', () => { + it('should load project metadata resources on init', () => { setup(); - expect(component).toBeTruthy(); - }); - it('should handle tab change for OSF tab', () => { - setup(); - const tabId = 'osf'; - const navigateSpy = vi.spyOn(routerMock, 'navigate'); + expect(store.dispatch).toHaveBeenCalledWith(new GetResourceMetadata(defaultResourceId, ResourceType.Project)); + expect(store.dispatch).toHaveBeenCalledWith(new GetCustomItemMetadata(defaultResourceId)); + expect(store.dispatch).toHaveBeenCalledWith( + new GetBibliographicContributors(defaultResourceId, ResourceType.Project) + ); + expect(store.dispatch).toHaveBeenCalledWith(new FetchResourceInstitutions(defaultResourceId, ResourceType.Project)); + expect(store.dispatch).toHaveBeenCalledWith(new GetCedarMetadataRecords(defaultResourceId, ResourceType.Project)); + expect(store.dispatch).toHaveBeenCalledWith(new GetCedarMetadataTemplates()); + expect(store.dispatch).toHaveBeenCalledWith(new FetchSelectedSubjects(defaultResourceId, ResourceType.Project)); + expect(store.dispatch).toHaveBeenCalledWith(new GetProjectSubmissions(defaultResourceId)); + expect(store.dispatch).toHaveBeenCalledWith(new FetchSubjects(ResourceType.Project)); + expect(signpostingServiceMock.addMetadataSignposting).toHaveBeenCalledWith(defaultResourceId); + }); - component.onTabChange(tabId); + it('should not fetch project submissions for registrations', () => { + setup({ resourceType: ResourceType.Registration }); - expect(component.selectedTab()).toBe(tabId); - expect(navigateSpy).toHaveBeenCalledTimes(0); + expect(store.dispatch).not.toHaveBeenCalledWith(new GetProjectSubmissions(defaultResourceId)); + expect(store.dispatch).toHaveBeenCalledWith(new FetchSubjects(ResourceType.Registration, 'osf')); }); - it('should toggle edit mode', () => { + it('should remove signposting tags on destroy', () => { setup(); - const initialReadonly = component.cedarFormReadonly(); - component.toggleEditMode(); + fixture.destroy(); - expect(component.cedarFormReadonly()).toBe(!initialReadonly); + expect(signpostingServiceMock.removeSignpostingLinkTags).toHaveBeenCalled(); }); - it('should handle tags changed', () => { - setup(); - const tags = ['tag1', 'tag2']; + it('should compute loading and resource type flags', () => { + setup({ + selectorOverrides: [{ selector: MetadataSelectors.getLoading, value: true }], + }); - expect(() => component.onTagsChanged(tags)).not.toThrow(); + expect(component.isLoading()).toBe(true); + expect(component.isProjectType()).toBe(true); + expect(component.isRegistrationType()).toBe(false); }); - it('should open edit contributor dialog', () => { + it('should hide edit doi for public projects that already have identifiers', () => { setup(); - const openSpy = vi.spyOn(customDialogServiceMock, 'open'); - expect(openSpy).toHaveBeenCalledTimes(0); - expect(() => component.openEditContributorDialog()).toThrow(); - expect(openSpy).toHaveBeenCalled(); + expect(component.hideEditDoi()).toBe(true); }); - it('should open edit title dialog', () => { - setup(); - const openSpy = vi.spyOn(customDialogServiceMock, 'open'); - - component.openEditTitleDialog(); + it('should mark tags and subjects read-only when project is read-only', () => { + setup({ + selectorOverrides: [{ selector: UserSelectors.isProjectReadOnly, value: true }], + }); - expect(openSpy).toHaveBeenCalled(); + expect(component.isTagsReadOnly()).toBe(true); + expect(component.isSubjectsReadOnly()).toBe(true); + expect(component.disabledButtonTooltip()).toBe('common.errorMessages.actionUnavailable'); }); - it('should open edit description dialog', () => { + it('should select osf tab without navigation', () => { setup(); - const openSpy = vi.spyOn(customDialogServiceMock, 'open'); + const navigateSpy = vi.spyOn(routerMock, 'navigate'); - component.openEditDescriptionDialog(); + component.onTabChange('osf'); - expect(openSpy).toHaveBeenCalled(); + expect(component.selectedTab()).toBe('osf'); + expect(navigateSpy).not.toHaveBeenCalled(); }); - it('should open edit resource information dialog', () => { + it('should toggle cedar edit mode', () => { setup(); - const openSpy = vi.spyOn(customDialogServiceMock, 'open'); + const initialReadonly = component.cedarFormReadonly(); - component.openEditResourceInformationDialog(); + component.toggleEditMode(); - expect(openSpy).toHaveBeenCalled(); + expect(component.cedarFormReadonly()).toBe(!initialReadonly); }); - it('should show resource info tooltip', () => { + it('should dispatch metadata update when tags change', () => { setup(); - const openSpy = vi.spyOn(customDialogServiceMock, 'open'); + (store.dispatch as ReturnType).mockClear(); - component.onShowResourceInfo(); + component.onTagsChanged(['tag1', 'tag2']); - expect(openSpy).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateResourceDetails(defaultResourceId, ResourceType.Project, { tags: ['tag1', 'tag2'] }) + ); }); - it('should open edit license dialog', () => { + it('should load more contributors', () => { setup(); - const openSpy = vi.spyOn(customDialogServiceMock, 'open'); + (store.dispatch as ReturnType).mockClear(); - component.openEditLicenseDialog(); + component.handleLoadMoreContributors(); - expect(openSpy).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith( + new LoadMoreBibliographicContributors(defaultResourceId, ResourceType.Project) + ); }); - it('should open edit funding dialog', () => { + it('should fetch and update subjects', () => { setup(); - const openSpy = vi.spyOn(customDialogServiceMock, 'open'); - - component.openEditFundingDialog(); - - expect(openSpy).toHaveBeenCalled(); + (store.dispatch as ReturnType).mockClear(); + + component.getSubjectChildren('parent-1'); + component.searchSubjects('psychology'); + component.updateSelectedSubjects([{ id: 'subject-1' } as never]); + + expect(store.dispatch).toHaveBeenCalledWith(new FetchChildrenSubjects('parent-1')); + expect(store.dispatch).toHaveBeenCalledWith( + new FetchSubjects(ResourceType.Project, component.provider, 'psychology') + ); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateResourceSubjects(defaultResourceId, ResourceType.Project, [{ id: 'subject-1' } as never]) + ); }); - it('should open edit affiliated institutions dialog', () => { + it('should open metadata edit dialogs with expected components', () => { setup(); - const openSpy = vi.spyOn(customDialogServiceMock, 'open'); + const openSpy = customDialogServiceMock.open; + component.openEditTitleDialog(); + component.openEditDescriptionDialog(); + component.openEditResourceInformationDialog(); + component.onShowResourceInfo(); + component.openEditFundingDialog(); component.openEditAffiliatedInstitutionsDialog(); - - expect(openSpy).toHaveBeenCalled(); + component.openEditContributorDialog(); + + expect(openSpy).toHaveBeenCalledWith(EditTitleDialogComponent, expect.objectContaining({ width: '500px' })); + expect(openSpy).toHaveBeenCalledWith(DescriptionDialogComponent, expect.objectContaining({ width: '500px' })); + expect(openSpy).toHaveBeenCalledWith( + ResourceInformationDialogComponent, + expect.objectContaining({ width: '500px' }) + ); + expect(openSpy).toHaveBeenCalledWith(ResourceInfoTooltipComponent, expect.objectContaining({ width: '850px' })); + expect(openSpy).toHaveBeenCalledWith(FundingDialogComponent, expect.objectContaining({ width: '600px' })); + expect(openSpy).toHaveBeenCalledWith( + AffiliatedInstitutionsDialogComponent, + expect.objectContaining({ width: '500px' }) + ); + expect(openSpy).toHaveBeenCalledWith( + ContributorsDialogComponent, + expect.objectContaining({ + data: { + resourceId: defaultResourceId, + resourceType: ResourceType.Project, + }, + }) + ); }); - it('should handle subject children fetch', () => { + it('should open license dialog with typed license data', () => { setup(); - const parentId = 'parent-subject-id'; - expect(() => component.getSubjectChildren(parentId)).not.toThrow(); + component.openEditLicenseDialog(); + + expect(customDialogServiceMock.open).toHaveBeenCalledWith( + LicenseDialogComponent, + expect.objectContaining({ + header: 'project.metadata.license.dialog.header', + width: '600px', + data: { + metadata: mockMetadata, + resourceType: ResourceType.Project, + providerId: component.provider, + }, + }) + ); }); - it('should handle subject search', () => { + it('should update resource license when license dialog closes with a result', () => { setup(); - const searchTerm = 'test search'; + customDialogServiceMock.open.mockReturnValue( + CustomDialogServiceMock.dialogRefWithClose( + of({ + licenseId: MOCK_LICENSE.id, + licenseOptions: { year: '2024', copyrightHolders: 'Jane Doe' }, + }) + ) + ); + (store.dispatch as ReturnType).mockClear(); + (store.dispatch as ReturnType).mockReturnValue(of(undefined)); - expect(() => component.searchSubjects(searchTerm)).not.toThrow(); + component.openEditLicenseDialog(); + + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateResourceLicense(defaultResourceId, ResourceType.Project, MOCK_LICENSE.id, { + year: '2024', + copyrightHolders: 'Jane Doe', + }) + ); + expect(toastServiceMock.showSuccess).toHaveBeenCalledWith('project.metadata.license.updated'); }); - it('should handle edit DOI for project', () => { + it('should confirm doi creation for projects', () => { setup(); - const confirmSpy = vi.spyOn(customConfirmationServiceMock, 'confirmDelete'); component.handleEditDoi(); - expect(confirmSpy).toHaveBeenCalled(); + expect(customConfirmationServiceMock.confirmDelete).toHaveBeenCalled(); }); - it('should open add record', () => { - setup(); - const navigateSpy = vi.spyOn(routerMock, 'navigate'); + it('should open publication doi dialog for registrations', () => { + setup({ resourceType: ResourceType.Registration }); - component.openAddRecord(); + component.handleEditDoi(); - expect(navigateSpy).toHaveBeenCalled(); + expect(customDialogServiceMock.open).toHaveBeenCalledWith( + PublicationDoiDialogComponent, + expect.objectContaining({ + header: 'project.metadata.doi.dialog.header', + data: mockMetadata.publicationDoi, + }) + ); }); - it('should handle cedar form change template', () => { + it('should navigate when opening add record or changing cedar template', () => { setup(); const navigateSpy = vi.spyOn(routerMock, 'navigate'); + component.openAddRecord(); component.onCedarFormChangeTemplate(); - expect(navigateSpy).toHaveBeenCalled(); - }); - - it('should handle isProjectReadOnly', () => { - setup([{ selector: UserSelectors.isProjectReadOnly, value: true }]); - - expect(component.isTagsReadOnly()).toBe(true); - expect(component.isSubjectsReadOnly()).toBe(true); - expect(component.disabledButtonTooltip()).toBe('common.errorMessages.actionUnavailable'); + expect(navigateSpy).toHaveBeenCalledTimes(2); }); }); diff --git a/src/app/features/metadata/metadata.component.ts b/src/app/features/metadata/metadata.component.ts index ebbde85c7..a9adf42ca 100644 --- a/src/app/features/metadata/metadata.component.ts +++ b/src/app/features/metadata/metadata.component.ts @@ -33,7 +33,6 @@ import { ContributorsSelectors, GetBibliographicContributors, LoadMoreBibliographicContributors, - UpdateContributorsSearchValue, } from '@osf/shared/stores/contributors'; import { FetchResourceInstitutions, @@ -74,6 +73,7 @@ import { LicenseDialogComponent } from './dialogs/license-dialog/license-dialog. import { PublicationDoiDialogComponent } from './dialogs/publication-doi-dialog/publication-doi-dialog.component'; import { ResourceInformationDialogComponent } from './dialogs/resource-information-dialog/resource-information-dialog.component'; import { ResourceInfoTooltipComponent } from './dialogs/resource-tooltip-info/resource-tooltip-info.component'; +import { LicenseDialogData, LicenseDialogResult } from './models/license-dialog.model'; import { CedarMetadataRecordModel, CedarMetadataTemplateModel, @@ -81,7 +81,6 @@ import { DialogValueModel, } from './models'; import { - CreateCedarMetadataRecord, CreateDoi, GetCedarMetadataRecords, GetCedarMetadataTemplates, @@ -184,14 +183,12 @@ export class MetadataComponent implements OnInit, OnDestroy { getCedarRecords: GetCedarMetadataRecords, getCedarTemplates: GetCedarMetadataTemplates, - createCedarRecord: CreateCedarMetadataRecord, updateCedarRecord: UpdateCedarMetadataRecord, fetchSubjects: FetchSubjects, fetchSelectedSubjects: FetchSelectedSubjects, fetchChildrenSubjects: FetchChildrenSubjects, updateResourceSubjects: UpdateResourceSubjects, - updateContributorsSearchValue: UpdateContributorsSearchValue, getProjectSubmissions: GetProjectSubmissions, }); @@ -308,7 +305,7 @@ export class MetadataComponent implements OnInit, OnDestroy { this.selectedTab.set(tab.id as MetadataResourceEnum); - if (tab.type === 'cedar') { + if (tab.type === MetadataResourceEnum.CEDAR) { this.selectedCedarRecord.set(null); this.selectedCedarTemplate.set(null); const currentRecordId = this.activeRoute.snapshot.paramMap.get('recordId'); @@ -466,24 +463,28 @@ export class MetadataComponent implements OnInit, OnDestroy { } openEditLicenseDialog(): void { + const data: LicenseDialogData = { + metadata: this.metadata(), + resourceType: this.resourceType(), + providerId: this.provider, + }; + this.customDialogService .open(LicenseDialogComponent, { header: 'project.metadata.license.dialog.header', width: '600px', - data: { - metadata: this.metadata(), - }, + data, }) .onClose.pipe( - filter((result) => !!result && result.licenseId), - switchMap((result) => { - return this.actions.updateResourceLicense( + filter((result): result is LicenseDialogResult => !!result?.licenseId), + switchMap((result) => + this.actions.updateResourceLicense( this.resourceId, this.resourceType(), result.licenseId, result.licenseOptions - ); - }) + ) + ) ) .subscribe(() => this.toastService.showSuccess('project.metadata.license.updated')); } diff --git a/src/app/features/metadata/models/license-dialog.model.ts b/src/app/features/metadata/models/license-dialog.model.ts new file mode 100644 index 000000000..cc36664f0 --- /dev/null +++ b/src/app/features/metadata/models/license-dialog.model.ts @@ -0,0 +1,15 @@ +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { LicenseOptions } from '@osf/shared/models/license/license.model'; + +import { MetadataModel } from './metadata.model'; + +export interface LicenseDialogData { + metadata?: MetadataModel | null; + resourceType?: ResourceType; + providerId?: string; +} + +export interface LicenseDialogResult { + licenseId: string; + licenseOptions?: LicenseOptions; +} diff --git a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-metadata-step.component.html b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-metadata-step.component.html index 11247062e..0f7f06e0e 100644 --- a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-metadata-step.component.html +++ b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-metadata-step.component.html @@ -12,7 +12,7 @@

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

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

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

-

+

{{ 'shared.license.helpText' | translate }} {{ 'common.links.helpGuide' | translate }}. @@ -23,7 +23,6 @@

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

[licenses]="licenses()" [selectedLicenseId]="createdPreprint()?.licenseId || defaultLicense()" [selectedLicenseOptions]="createdPreprint()?.licenseOptions" - [fullWidthSelect]="true" (createLicense)="createLicense($event)" (selectLicense)="selectLicense($event)" /> diff --git a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-metadata-step.component.ts b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-metadata-step.component.ts index 45b99d832..f92ac3f51 100644 --- a/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-metadata-step.component.ts +++ b/src/app/features/preprints/components/stepper/preprints-metadata-step/preprints-metadata-step.component.ts @@ -29,7 +29,7 @@ import { CustomValidators } from '@osf/shared/helpers/custom-form-validators.hel import { findChangedFields } from '@osf/shared/helpers/find-changed-fields'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { ToastService } from '@osf/shared/services/toast.service'; -import { LicenseModel, LicenseOptions } from '@shared/models/license/license.model'; +import { LicenseDetails, LicenseModel } from '@shared/models/license/license.model'; import { PreprintsAffiliatedInstitutionsComponent } from './preprints-affiliated-institutions/preprints-affiliated-institutions.component'; import { PreprintsContributorsComponent } from './preprints-contributors/preprints-contributors.component'; @@ -160,7 +160,7 @@ export class PreprintsMetadataStepComponent implements OnInit { this.deleteClicked.emit(); } - createLicense(licenseDetails: { id: string; licenseOptions: LicenseOptions }) { + createLicense(licenseDetails: LicenseDetails) { this.actions.saveLicense(licenseDetails.id, licenseDetails.licenseOptions); } diff --git a/src/app/features/preprints/services/preprint-licenses.service.spec.ts b/src/app/features/preprints/services/preprint-licenses.service.spec.ts new file mode 100644 index 000000000..7010e187e --- /dev/null +++ b/src/app/features/preprints/services/preprint-licenses.service.spec.ts @@ -0,0 +1,102 @@ +import { MockProvider } from 'ng-mocks'; + +import { firstValueFrom, of } from 'rxjs'; + +import { TestBed } from '@angular/core/testing'; + +import { PreprintsMapper } from '@osf/features/preprints/mappers'; +import { LicenseOptions } from '@osf/shared/models/license/license.model'; +import { PreprintDataJsonApi } from '@osf/shared/models/preprints/preprint-json-api.model'; +import { JsonApiService } from '@osf/shared/services/json-api.service'; + +import { PREPRINT_MOCK } from '@testing/mocks/preprint.mock'; +import { MOCK_API_URL, provideMockEnvironment } from '@testing/providers/environment.provider.mock'; +import { JsonApiServiceMock, JsonApiServiceMockType } from '@testing/providers/json-api.service.mock'; + +import { PreprintLicensesService } from './preprint-licenses.service'; + +describe('PreprintLicensesService', () => { + let service: PreprintLicensesService; + let jsonApiService: JsonApiServiceMockType; + + const preprintId = 'preprint-1'; + const licenseId = 'license-123'; + const apiResponse = { id: preprintId } as PreprintDataJsonApi; + + function setup() { + jsonApiService = JsonApiServiceMock.simple(); + + TestBed.configureTestingModule({ + providers: [PreprintLicensesService, MockProvider(JsonApiService, jsonApiService), provideMockEnvironment()], + }); + + service = TestBed.inject(PreprintLicensesService); + } + + it('should expose apiUrl from environment', () => { + setup(); + + expect(service.apiUrl).toBe(MOCK_API_URL); + }); + + it('updatePreprintLicense should patch without license_record when options are omitted', async () => { + setup(); + const mapperSpy = vi.spyOn(PreprintsMapper, 'fromPreprintJsonApi').mockReturnValue(PREPRINT_MOCK); + jsonApiService.patch.mockReturnValue(of(apiResponse)); + + const response = await firstValueFrom(service.updatePreprintLicense(preprintId, licenseId)); + + expect(jsonApiService.patch).toHaveBeenCalledWith(`${MOCK_API_URL}/preprints/${preprintId}/`, { + data: { + type: 'preprints', + id: preprintId, + relationships: { + license: { + data: { + id: licenseId, + type: 'licenses', + }, + }, + }, + attributes: {}, + }, + }); + expect(mapperSpy).toHaveBeenCalledWith(apiResponse); + expect(response).toEqual(PREPRINT_MOCK); + }); + + it('updatePreprintLicense should patch with license_record when options are provided', async () => { + setup(); + const licenseOptions: LicenseOptions = { + copyrightHolders: 'John Doe', + year: '2023', + }; + const mapperSpy = vi.spyOn(PreprintsMapper, 'fromPreprintJsonApi').mockReturnValue(PREPRINT_MOCK); + jsonApiService.patch.mockReturnValue(of(apiResponse)); + + const response = await firstValueFrom(service.updatePreprintLicense(preprintId, licenseId, licenseOptions)); + + expect(jsonApiService.patch).toHaveBeenCalledWith(`${MOCK_API_URL}/preprints/${preprintId}/`, { + data: { + type: 'preprints', + id: preprintId, + relationships: { + license: { + data: { + id: licenseId, + type: 'licenses', + }, + }, + }, + attributes: { + license_record: { + copyright_holders: [licenseOptions.copyrightHolders], + year: licenseOptions.year, + }, + }, + }, + }); + expect(mapperSpy).toHaveBeenCalledWith(apiResponse); + expect(response).toEqual(PREPRINT_MOCK); + }); +}); diff --git a/src/app/features/preprints/services/preprint-licenses.service.ts b/src/app/features/preprints/services/preprint-licenses.service.ts index 6f8de72cf..fbb9acbbe 100644 --- a/src/app/features/preprints/services/preprint-licenses.service.ts +++ b/src/app/features/preprints/services/preprint-licenses.service.ts @@ -1,4 +1,4 @@ -import { map, Observable } from 'rxjs'; +import { map } from 'rxjs'; import { inject, Injectable } from '@angular/core'; @@ -6,9 +6,7 @@ import { ENVIRONMENT } from '@core/provider/environment.provider'; import { PreprintsMapper } from '@osf/features/preprints/mappers'; import { PreprintDataJsonApi } from '@osf/shared/models/preprints/preprint-json-api.model'; import { JsonApiService } from '@osf/shared/services/json-api.service'; -import { LicensesMapper } from '@shared/mappers/licenses.mapper'; -import { LicenseModel, LicenseOptions } from '@shared/models/license/license.model'; -import { LicensesResponseJsonApi } from '@shared/models/license/licenses-json-api.model'; +import { LicenseOptions } from '@shared/models/license/license.model'; import { PreprintLicensePayloadJsonApi } from '../models'; @@ -23,15 +21,6 @@ export class PreprintLicensesService { return `${this.environment.apiDomainUrl}/v2`; } - getLicenses(providerId: string): Observable { - return this.jsonApiService - .get(`${this.apiUrl}/providers/preprints/${providerId}/licenses/`, { - 'page[size]': 100, - sort: 'name', - }) - .pipe(map((licenses) => LicensesMapper.fromLicensesResponse(licenses))); - } - updatePreprintLicense(preprintId: string, licenseId: string, licenseOptions?: LicenseOptions) { const payload: PreprintLicensePayloadJsonApi = { data: { diff --git a/src/app/features/preprints/store/preprint-stepper/preprint-stepper.state.ts b/src/app/features/preprints/store/preprint-stepper/preprint-stepper.state.ts index ff1e17cc7..4404bdc01 100644 --- a/src/app/features/preprints/store/preprint-stepper/preprint-stepper.state.ts +++ b/src/app/features/preprints/store/preprint-stepper/preprint-stepper.state.ts @@ -8,10 +8,12 @@ import { HttpEventType } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; import { PreprintModel } from '@osf/features/preprints/models'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { handleSectionError } from '@osf/shared/helpers/state-error.handler'; import { FileModel } from '@osf/shared/models/files/file.model'; import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; import { FilesService } from '@osf/shared/services/files.service'; +import { LicensesService } from '@osf/shared/services/licenses.service'; import { PreprintFilesService } from '../../services/preprint-files.service'; import { PreprintLicensesService } from '../../services/preprint-licenses.service'; @@ -55,7 +57,8 @@ export class PreprintStepperState { private preprintsService = inject(PreprintsService); private preprintFilesService = inject(PreprintFilesService); private fileService = inject(FilesService); - private licensesService = inject(PreprintLicensesService); + private preprintLicensesService = inject(PreprintLicensesService); + private licensesService = inject(LicensesService); private preprintProjectsService = inject(PreprintsProjectsService); @Action(CreatePreprint) @@ -318,7 +321,7 @@ export class PreprintStepperState { fetchLicenses(ctx: StateContext, action: FetchLicenses) { ctx.setState(patch({ licenses: patch({ isLoading: true }) })); - return this.licensesService.getLicenses(action.providerId).pipe( + return this.licensesService.getLicenses(ResourceType.Preprint, action.providerId).pipe( tap((licenses) => { ctx.setState(patch({ licenses: patch({ isLoading: false, data: licenses }) })); }), @@ -331,12 +334,14 @@ export class PreprintStepperState { const createdPreprintId = ctx.getState().preprint.data!.id; ctx.setState(patch({ preprint: patch({ isSubmitting: true }) })); - return this.licensesService.updatePreprintLicense(createdPreprintId, action.licenseId, action.licenseOptions).pipe( - tap((preprint) => { - ctx.setState(patch({ preprint: patch({ isSubmitting: false, data: preprint }) })); - }), - catchError((error) => handleSectionError(ctx, 'preprint', error)) - ); + return this.preprintLicensesService + .updatePreprintLicense(createdPreprintId, action.licenseId, action.licenseOptions) + .pipe( + tap((preprint) => { + ctx.setState(patch({ preprint: patch({ isSubmitting: false, data: preprint }) })); + }), + catchError((error) => handleSectionError(ctx, 'preprint', error)) + ); } @Action(DisconnectProject) diff --git a/src/app/features/project/overview/services/project-overview.service.spec.ts b/src/app/features/project/overview/services/project-overview.service.spec.ts index 314248cf7..7c0d1d9ed 100644 --- a/src/app/features/project/overview/services/project-overview.service.spec.ts +++ b/src/app/features/project/overview/services/project-overview.service.spec.ts @@ -9,21 +9,18 @@ import { BYPASS_ERROR_INTERCEPTOR } from '@core/interceptors/error-interceptor.t import { JSON_API_BULK_HEADERS } from '@osf/shared/constants/json-api-headers.const'; import { IdentifiersMapper } from '@osf/shared/mappers/identifiers.mapper'; import { InstitutionsMapper } from '@osf/shared/mappers/institutions'; -import { LicensesMapper } from '@osf/shared/mappers/licenses.mapper'; import { BaseNodeMapper } from '@osf/shared/mappers/nodes'; import { NodePreprintMapper } from '@osf/shared/mappers/nodes/node-preprint.mapper'; import { NodeStorageMapper } from '@osf/shared/mappers/nodes/node-storage.mapper'; import { IdentifiersResponseJsonApi } from '@osf/shared/models/identifiers/identifier-json-api.model'; import { InstitutionsJsonApiResponse } from '@osf/shared/models/institutions/institution-json-api.model'; import { Institution } from '@osf/shared/models/institutions/institutions.model'; -import { LicenseResponseJsonApi } from '@osf/shared/models/license/licenses-json-api.model'; import { BaseNodeDataJsonApi } from '@osf/shared/models/nodes/base-node-data-json-api.model'; import { NodePreprintsResponseJsonApi } from '@osf/shared/models/nodes/node-preprint-json-api.model'; import { NodeStorageModel } from '@osf/shared/models/nodes/node-storage.model'; import { NodeResponseJsonApi, NodesResponseJsonApi } from '@osf/shared/models/nodes/nodes-json-api.model'; import { PaginatedData } from '@osf/shared/models/paginated-data.model'; import { JsonApiService } from '@osf/shared/services/json-api.service'; -import { LicenseModel } from '@shared/models/license/license.model'; import { MOCK_NODE_WITH_ADMIN } from '@testing/mocks/node.mock'; import { MOCK_NODE_PREPRINTS } from '@testing/mocks/node-preprint.mock'; @@ -121,21 +118,6 @@ describe('ProjectOverviewService', () => { expect(response).toEqual([MOCK_PROJECT_IDENTIFIERS]); }); - it('getProjectLicense should request license and map response', async () => { - setup(); - const licenseId = 'license-1'; - const mappedLicense = { id: licenseId, name: 'MIT' } as LicenseModel; - const apiResponse = { data: { id: licenseId } } as LicenseResponseJsonApi; - const mapperSpy = vi.spyOn(LicensesMapper, 'fromLicenseDataJsonApi').mockReturnValue(mappedLicense); - jsonApiService.get.mockReturnValue(of(apiResponse)); - - const response = await firstValueFrom(service.getProjectLicense(licenseId)); - - expect(jsonApiService.get).toHaveBeenCalledWith(`${MOCK_API_URL}/licenses/${licenseId}/`); - expect(mapperSpy).toHaveBeenCalledWith(apiResponse.data); - expect(response).toEqual(mappedLicense); - }); - it('getProjectStorage should request storage and map response', async () => { setup(); const mappedStorage = { storageLimitStatus: 'DEFAULT', storageUsage: '1' } as NodeStorageModel; diff --git a/src/app/features/project/overview/services/project-overview.service.ts b/src/app/features/project/overview/services/project-overview.service.ts index 9b93a910c..e31e76159 100644 --- a/src/app/features/project/overview/services/project-overview.service.ts +++ b/src/app/features/project/overview/services/project-overview.service.ts @@ -7,14 +7,12 @@ import { ENVIRONMENT } from '@core/provider/environment.provider'; import { JSON_API_BULK_HEADERS } from '@osf/shared/constants/json-api-headers.const'; import { IdentifiersMapper } from '@osf/shared/mappers/identifiers.mapper'; import { InstitutionsMapper } from '@osf/shared/mappers/institutions'; -import { LicensesMapper } from '@osf/shared/mappers/licenses.mapper'; import { BaseNodeMapper } from '@osf/shared/mappers/nodes'; import { NodePreprintMapper } from '@osf/shared/mappers/nodes/node-preprint.mapper'; import { NodeStorageMapper } from '@osf/shared/mappers/nodes/node-storage.mapper'; import { IdentifiersResponseJsonApi } from '@osf/shared/models/identifiers/identifier-json-api.model'; import { InstitutionsJsonApiResponse } from '@osf/shared/models/institutions/institution-json-api.model'; import { Institution } from '@osf/shared/models/institutions/institutions.model'; -import { LicenseResponseJsonApi } from '@osf/shared/models/license/licenses-json-api.model'; import { BaseNodeModel, NodeModel } from '@osf/shared/models/nodes/base-node.model'; import { BaseNodeDataJsonApi } from '@osf/shared/models/nodes/base-node-data-json-api.model'; import { NodePreprintModel } from '@osf/shared/models/nodes/node-preprint.model'; @@ -25,7 +23,6 @@ import { NodeResponseJsonApi, NodesResponseJsonApi } from '@osf/shared/models/no import { PaginatedData } from '@osf/shared/models/paginated-data.model'; import { JsonApiService } from '@osf/shared/services/json-api.service'; import { IdentifierModel } from '@shared/models/identifiers/identifier.model'; -import { LicenseModel } from '@shared/models/license/license.model'; import { ProjectOverviewMapper } from '../mappers'; import { PrivacyStatusModel, ProjectOverviewModel } from '../models'; @@ -68,12 +65,6 @@ export class ProjectOverviewService { .pipe(map((response) => IdentifiersMapper.fromJsonApi(response))); } - getProjectLicense(licenseId: string): Observable { - return this.jsonApiService - .get(`${this.apiUrl}/licenses/${licenseId}/`) - .pipe(map((response) => LicensesMapper.fromLicenseDataJsonApi(response.data))); - } - getProjectStorage(projectId: string): Observable { return this.jsonApiService .get(`${this.apiUrl}/nodes/${projectId}/storage/`) diff --git a/src/app/features/project/overview/store/project-overview.state.ts b/src/app/features/project/overview/store/project-overview.state.ts index 8cd50508f..c1b8e316e 100644 --- a/src/app/features/project/overview/store/project-overview.state.ts +++ b/src/app/features/project/overview/store/project-overview.state.ts @@ -7,6 +7,7 @@ import { inject, Injectable } from '@angular/core'; import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; import { handleSectionError } from '@osf/shared/helpers/state-error.handler'; import { DuplicatesService } from '@osf/shared/services/duplicates.service'; +import { LicensesService } from '@osf/shared/services/licenses.service'; import { ProjectOverviewService } from '../services/project-overview.service'; @@ -39,6 +40,7 @@ import { PROJECT_OVERVIEW_DEFAULTS, ProjectOverviewStateModel } from './project- export class ProjectOverviewState { private readonly projectOverviewService = inject(ProjectOverviewService); private readonly duplicatesService = inject(DuplicatesService); + private readonly licensesService = inject(LicensesService); @Action(GetProjectById) getProjectById(ctx: StateContext, action: GetProjectById) { @@ -147,7 +149,7 @@ export class ProjectOverviewState { }, }); - return this.projectOverviewService.getProjectLicense(action.licenseId).pipe( + return this.licensesService.getLicenseById(action.licenseId).pipe( tap((license) => { ctx.patchState({ license: { diff --git a/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.html b/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.html index 694fe910e..2b411cdd5 100644 --- a/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.html +++ b/src/app/features/registries/components/registries-metadata-step/registries-license/registries-license.component.html @@ -2,7 +2,7 @@

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

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

-

+

{{ 'shared.license.helpText' | translate }} {{ 'common.links.helpGuide' | translate }}{{ 'common.labels.license' | translate }}

{ let fixture: ComponentFixture; let store: Store; let licensesSignal: WritableSignal; - let selectedLicenseSignal: WritableSignal<{ id: string; options?: Record } | null>; - let draftRegistrationSignal: WritableSignal | null>; + let selectedLicenseSignal: WritableSignal; + 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(() => { + function setup(draft: Partial | null = { providerId: 'osf' }) { licensesSignal = signal([]); - selectedLicenseSignal = signal<{ id: string; options?: Record } | null>(null); - draftRegistrationSignal = signal | null>({ - providerId: 'osf', - }); + selectedLicenseSignal = signal(null); + draftRegistrationSignal = signal(draft); TestBed.configureTestingModule({ imports: [RegistriesLicenseComponent, MockComponent(LicenseComponent)], @@ -55,17 +54,25 @@ describe('RegistriesLicenseComponent', () => { fixture.componentRef.setInput('control', new FormGroup({ id: new FormControl('') })); fixture.componentRef.setInput('draftId', 'draft-1'); fixture.detectChanges(); - }); + } it('should create', () => { + setup(); expect(component).toBeTruthy(); }); it('should dispatch fetchLicenses on init when draft present', () => { + setup(); + expect(store.dispatch).toHaveBeenCalledWith(new FetchLicenses('osf')); + }); + + it('should fetch licenses with defaultProvider when draft has no providerId', () => { + setup({}); expect(store.dispatch).toHaveBeenCalledWith(new FetchLicenses('osf')); }); it('should fetch licenses only once even if draft re-emits', () => { + setup(); (store.dispatch as Mock).mockClear(); draftRegistrationSignal.set({ providerId: 'other' }); fixture.detectChanges(); @@ -73,15 +80,26 @@ describe('RegistriesLicenseComponent', () => { }); it('should sync selected license to control when license exists in list', () => { + setup(); licensesSignal.set([mockLicense]); - selectedLicenseSignal.set({ id: 'lic-1' }); + selectedLicenseSignal.set({ id: 'lic-1', options: null }); fixture.detectChanges(); expect(component.control().get('id')?.value).toBe('lic-1'); }); + it('should apply default license when selected license is not in the list', () => { + setup({ providerId: 'osf', defaultLicenseId: 'default-1' }); + (store.dispatch as Mock).mockClear(); + selectedLicenseSignal.set({ id: 'missing-lic', options: null }); + 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 and save when no selected license', () => { + setup({ providerId: 'osf', defaultLicenseId: 'default-1' }); (store.dispatch as Mock).mockClear(); - draftRegistrationSignal.set({ providerId: 'osf', defaultLicenseId: 'default-1' }); licensesSignal.set([mockDefaultLicense]); fixture.detectChanges(); expect(component.control().get('id')?.value).toBe('default-1'); @@ -89,6 +107,7 @@ describe('RegistriesLicenseComponent', () => { }); it('should apply default license but not save when it has required fields', () => { + setup({ providerId: 'osf', defaultLicenseId: 'default-2' }); (store.dispatch as Mock).mockClear(); const licenseWithFields: LicenseModel = { id: 'default-2', @@ -97,7 +116,6 @@ describe('RegistriesLicenseComponent', () => { url: '', text: '', }; - draftRegistrationSignal.set({ providerId: 'osf', defaultLicenseId: 'default-2' }); licensesSignal.set([licenseWithFields]); fixture.detectChanges(); expect(component.control().get('id')?.value).toBe('default-2'); @@ -105,14 +123,15 @@ describe('RegistriesLicenseComponent', () => { }); it('should prefer selected license over default license', () => { + setup({ providerId: 'osf', defaultLicenseId: 'default-1' }); licensesSignal.set([mockDefaultLicense, mockLicense]); - draftRegistrationSignal.set({ providerId: 'osf', defaultLicenseId: 'default-1' }); - selectedLicenseSignal.set({ id: 'lic-1' }); + selectedLicenseSignal.set({ id: 'lic-1', options: { year: '2024', copyrightHolders: 'Me' } }); fixture.detectChanges(); expect(component.control().get('id')?.value).toBe('lic-1'); }); it('should set control id and save license when selecting simple license', () => { + setup(); (store.dispatch as Mock).mockClear(); component.selectLicense(mockLicense); expect(component.control().get('id')?.value).toBe('lic-1'); @@ -120,22 +139,28 @@ describe('RegistriesLicenseComponent', () => { }); it('should not save when license has required fields', () => { + setup(); (store.dispatch as Mock).mockClear(); component.selectLicense({ id: 'lic-2', name: 'CC-BY', requiredFields: ['year'], url: '', text: '' }); expect(store.dispatch).not.toHaveBeenCalled(); }); it('should dispatch saveLicense with options on createLicense', () => { + setup(); (store.dispatch as Mock).mockClear(); - component.createLicense({ id: 'lic-3', licenseOptions: { year: '2024', copyrightHolders: 'Me' } }); + const licenseDetails: LicenseDetails = { + id: 'lic-3', + licenseOptions: { year: '2024', copyrightHolders: 'Me' }, + }; + component.createLicense(licenseDetails); 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', () => { + setup({ providerId: 'osf', defaultLicenseId: 'non-existent' }); (store.dispatch as Mock).mockClear(); - draftRegistrationSignal.set({ providerId: 'osf', defaultLicenseId: 'non-existent' }); licensesSignal.set([mockLicense]); fixture.detectChanges(); expect(component.control().get('id')?.value).toBe(''); @@ -143,6 +168,7 @@ describe('RegistriesLicenseComponent', () => { }); it('should mark control on focus out', () => { + setup(); component.onFocusOut(); 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 7225338ca..9572a19bf 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 @@ -12,7 +12,7 @@ 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 { INPUT_VALIDATION_MESSAGES } from '@osf/shared/constants/input-validation-messages.const'; -import { LicenseModel, LicenseOptions } from '@shared/models/license/license.model'; +import { LicenseDetails, LicenseModel } from '@shared/models/license/license.model'; @Component({ selector: 'osf-registries-license', @@ -60,7 +60,7 @@ export class RegistriesLicenseComponent { }); } - createLicense(licenseDetails: { id: string; licenseOptions: LicenseOptions }) { + createLicense(licenseDetails: LicenseDetails) { this.actions.saveLicense(this.draftId(), licenseDetails.id, licenseDetails.licenseOptions); } diff --git a/src/app/features/registries/mappers/index.ts b/src/app/features/registries/mappers/index.ts deleted file mode 100644 index 8c8a64cf3..000000000 --- a/src/app/features/registries/mappers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './licenses.mapper'; diff --git a/src/app/features/registries/mappers/licenses.mapper.ts b/src/app/features/registries/mappers/licenses.mapper.ts deleted file mode 100644 index afac806d9..000000000 --- a/src/app/features/registries/mappers/licenses.mapper.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { LicenseModel } from '@osf/shared/models/license/license.model'; -import { LicensesResponseJsonApi } from '@osf/shared/models/license/licenses-json-api.model'; - -export class LicensesMapper { - static fromLicensesResponse(response: LicensesResponseJsonApi): LicenseModel[] { - return response.data.map((item) => ({ - id: item.id, - name: item.attributes.name, - requiredFields: item.attributes.required_fields, - url: item.attributes.url, - text: item.attributes.text, - })); - } -} diff --git a/src/app/features/registries/services/registrations-licenses.service.spec.ts b/src/app/features/registries/services/registrations-licenses.service.spec.ts new file mode 100644 index 000000000..f3d958fdd --- /dev/null +++ b/src/app/features/registries/services/registrations-licenses.service.spec.ts @@ -0,0 +1,109 @@ +import { MockProvider } from 'ng-mocks'; + +import { firstValueFrom, of } from 'rxjs'; + +import { TestBed } from '@angular/core/testing'; + +import { RegistrationMapper } from '@osf/shared/mappers/registration'; +import { LicenseOptions } from '@osf/shared/models/license/license.model'; +import { DraftRegistrationModel } from '@osf/shared/models/registration/draft-registration.model'; +import { DraftRegistrationDataJsonApi } from '@osf/shared/models/registration/registration-json-api.model'; +import { JsonApiService } from '@osf/shared/services/json-api.service'; + +import { MOCK_DRAFT_REGISTRATION } from '@testing/mocks/registries.mock'; +import { MOCK_API_URL, provideMockEnvironment } from '@testing/providers/environment.provider.mock'; +import { JsonApiServiceMock, JsonApiServiceMockType } from '@testing/providers/json-api.service.mock'; + +import { RegistrationsLicenseService } from './registrations-licenses.service'; + +describe('RegistrationsLicenseService', () => { + let service: RegistrationsLicenseService; + let jsonApiService: JsonApiServiceMockType; + + const registrationId = 'draft-1'; + const licenseId = 'license-123'; + const apiResponse = { id: registrationId } as DraftRegistrationDataJsonApi; + const mappedDraft = MOCK_DRAFT_REGISTRATION as DraftRegistrationModel; + + function setup() { + jsonApiService = JsonApiServiceMock.simple(); + + TestBed.configureTestingModule({ + providers: [RegistrationsLicenseService, MockProvider(JsonApiService, jsonApiService), provideMockEnvironment()], + }); + + service = TestBed.inject(RegistrationsLicenseService); + } + + it('should expose apiUrl from environment', () => { + setup(); + + expect(service.apiUrl).toBe(MOCK_API_URL); + }); + + it('updateLicense should patch with empty node_license when options are omitted', async () => { + setup(); + const mapperSpy = vi.spyOn(RegistrationMapper, 'fromDraftRegistrationResponse').mockReturnValue(mappedDraft); + jsonApiService.patch.mockReturnValue(of(apiResponse)); + + const response = await firstValueFrom(service.updateLicense(registrationId, licenseId)); + + expect(jsonApiService.patch).toHaveBeenCalledWith(`${MOCK_API_URL}/draft_registrations/${registrationId}/`, { + data: { + type: 'draft_registrations', + id: registrationId, + relationships: { + license: { + data: { + id: licenseId, + type: 'licenses', + }, + }, + }, + attributes: { + node_license: { + copyright_holders: [], + year: '', + }, + }, + }, + }); + expect(mapperSpy).toHaveBeenCalledWith(apiResponse); + expect(response).toEqual(mappedDraft); + }); + + it('updateLicense should patch with node_license when options are provided', async () => { + setup(); + const licenseOptions: LicenseOptions = { + copyrightHolders: 'John Doe', + year: '2023', + }; + const mapperSpy = vi.spyOn(RegistrationMapper, 'fromDraftRegistrationResponse').mockReturnValue(mappedDraft); + jsonApiService.patch.mockReturnValue(of(apiResponse)); + + const response = await firstValueFrom(service.updateLicense(registrationId, licenseId, licenseOptions)); + + expect(jsonApiService.patch).toHaveBeenCalledWith(`${MOCK_API_URL}/draft_registrations/${registrationId}/`, { + data: { + type: 'draft_registrations', + id: registrationId, + relationships: { + license: { + data: { + id: licenseId, + type: 'licenses', + }, + }, + }, + attributes: { + node_license: { + copyright_holders: [licenseOptions.copyrightHolders], + year: licenseOptions.year, + }, + }, + }, + }); + expect(mapperSpy).toHaveBeenCalledWith(apiResponse); + expect(response).toEqual(mappedDraft); + }); +}); diff --git a/src/app/features/registries/services/registrations-licenses.service.ts b/src/app/features/registries/services/registrations-licenses.service.ts index 6d0317db9..4377c197b 100644 --- a/src/app/features/registries/services/registrations-licenses.service.ts +++ b/src/app/features/registries/services/registrations-licenses.service.ts @@ -4,8 +4,7 @@ import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { RegistrationMapper } from '@osf/shared/mappers/registration'; -import { LicenseModel, LicenseOptions } from '@osf/shared/models/license/license.model'; -import { LicensesResponseJsonApi } from '@osf/shared/models/license/licenses-json-api.model'; +import { LicenseOptions } from '@osf/shared/models/license/license.model'; import { DraftRegistrationModel } from '@osf/shared/models/registration/draft-registration.model'; import { CreateRegistrationPayloadJsonApi, @@ -13,8 +12,6 @@ import { } from '@osf/shared/models/registration/registration-json-api.model'; import { JsonApiService } from '@osf/shared/services/json-api.service'; -import { LicensesMapper } from '../mappers'; - @Injectable({ providedIn: 'root', }) @@ -26,14 +23,6 @@ export class RegistrationsLicenseService { return `${this.environment.apiDomainUrl}/v2`; } - getLicenses(providerId: string): Observable { - return this.jsonApiService - .get(`${this.apiUrl}/providers/registrations/${providerId}/licenses/`, { - 'page[size]': 100, - }) - .pipe(map((licenses) => LicensesMapper.fromLicensesResponse(licenses))); - } - updateLicense( registrationId: string, licenseId: string, diff --git a/src/app/features/registries/store/handlers/licenses.handlers.ts b/src/app/features/registries/store/handlers/licenses.handlers.ts index 1ba7b8a68..f14d8d972 100644 --- a/src/app/features/registries/store/handlers/licenses.handlers.ts +++ b/src/app/features/registries/store/handlers/licenses.handlers.ts @@ -4,7 +4,9 @@ import { catchError, tap } from 'rxjs'; import { inject, Injectable } from '@angular/core'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { handleSectionError } from '@osf/shared/helpers/state-error.handler'; +import { LicensesService } from '@osf/shared/services/licenses.service'; import { RegistrationsLicenseService } from '../../services/registrations-licenses.service'; import { SaveLicense } from '../registries.actions'; @@ -13,6 +15,7 @@ import { RegistriesStateModel } from '../registries.model'; @Injectable() export class LicensesHandlers { registrationsLicenseService = inject(RegistrationsLicenseService); + licensesService = inject(LicensesService); fetchLicenses(ctx: StateContext, providerId: string) { ctx.patchState({ @@ -22,7 +25,7 @@ export class LicensesHandlers { }, }); - return this.registrationsLicenseService.getLicenses(providerId).pipe( + return this.licensesService.getLicenses(ResourceType.Registration, providerId).pipe( tap((licenses) => { ctx.patchState({ licenses: { diff --git a/src/app/features/registry/services/registry-overview.service.spec.ts b/src/app/features/registry/services/registry-overview.service.spec.ts new file mode 100644 index 000000000..bfb0c2d1d --- /dev/null +++ b/src/app/features/registry/services/registry-overview.service.spec.ts @@ -0,0 +1,338 @@ +import { MockProvider } from 'ng-mocks'; + +import { firstValueFrom, of } from 'rxjs'; + +import { TestBed } from '@angular/core/testing'; + +import { RegistryModerationMapper } from '@osf/features/moderation/mappers'; +import { ReviewAction, ReviewActionsResponseJsonApi } from '@osf/features/moderation/models'; +import { RegistrationReviewStates } from '@osf/shared/enums/registration-review-states.enum'; +import { RevisionReviewStates } from '@osf/shared/enums/revision-review-states.enum'; +import { IdentifiersMapper } from '@osf/shared/mappers/identifiers.mapper'; +import { InstitutionsMapper } from '@osf/shared/mappers/institutions'; +import { PageSchemaMapper, RegistrationMapper, RegistrationNodeMapper } from '@osf/shared/mappers/registration'; +import { ReviewActionsMapper } from '@osf/shared/mappers/review-actions.mapper'; +import { IdentifiersResponseJsonApi } from '@osf/shared/models/identifiers/identifier-json-api.model'; +import { InstitutionsJsonApiResponse } from '@osf/shared/models/institutions/institution-json-api.model'; +import { Institution } from '@osf/shared/models/institutions/institutions.model'; +import { SchemaBlocksResponseJsonApi } from '@osf/shared/models/registration/schema-blocks-json-api.model'; +import { + SchemaResponseJsonApi, + SchemaResponsesJsonApi, +} from '@osf/shared/models/registration/schema-response-json-api.model'; +import { ReviewActionPayload } from '@osf/shared/models/review-action/review-action-payload.model'; +import { ReviewActionPayloadJsonApi } from '@osf/shared/models/review-action/review-action-payload-json-api.model'; +import { JsonApiService } from '@osf/shared/services/json-api.service'; + +import { MOCK_INSTITUTION } from '@testing/mocks/institution.mock'; +import { MOCK_PROJECT_IDENTIFIERS } from '@testing/mocks/project-overview.mock'; +import { MOCK_REGISTRATION_OVERVIEW_MODEL } from '@testing/mocks/registration-overview-model.mock'; +import { MOCK_PAGES_SCHEMA } from '@testing/mocks/registries.mock'; +import { REVIEW_ACTION_MOCK } from '@testing/mocks/review-action.mock'; +import { createMockSchemaResponse } from '@testing/mocks/schema-response.mock'; +import { MOCK_API_URL, provideMockEnvironment } from '@testing/providers/environment.provider.mock'; +import { JsonApiServiceMock, JsonApiServiceMockType } from '@testing/providers/json-api.service.mock'; + +import { RegistrationOverviewDataJsonApi, RegistrationOverviewResponse } from '../models'; + +import { RegistryOverviewService } from './registry-overview.service'; + +describe('RegistryOverviewService', () => { + let service: RegistryOverviewService; + let jsonApiService: JsonApiServiceMockType; + + const registryId = 'registry-1'; + const registrationData = { + id: registryId, + attributes: { + pending_embargo_approval: false, + pending_embargo_termination_approval: false, + embargoed: false, + pending_registration_approval: false, + reviews_state: RegistrationReviewStates.Accepted, + revision_state: RevisionReviewStates.Approved, + }, + relationships: { + registered_from: { data: { id: 'project-1' } }, + registration_schema: { links: { related: { href: 'https://api.test/schema/' } } }, + license: { data: { id: 'license-1' } }, + provider: { data: { id: 'osf' } }, + root: { data: { id: 'root-1' } }, + forks: { links: { related: { meta: { count: 3 } } } }, + }, + } as RegistrationOverviewDataJsonApi; + const schemaResponse = createMockSchemaResponse('schema-response-1', RevisionReviewStates.Approved); + + function setup() { + jsonApiService = JsonApiServiceMock.simple(); + + TestBed.configureTestingModule({ + providers: [RegistryOverviewService, MockProvider(JsonApiService, jsonApiService), provideMockEnvironment()], + }); + + service = TestBed.inject(RegistryOverviewService); + } + + function stubRegistrationNodeMapper() { + return vi + .spyOn(RegistrationNodeMapper, 'getRegistrationNodeAttributes') + .mockReturnValue(MOCK_REGISTRATION_OVERVIEW_MODEL); + } + + it('should expose apiUrl from environment', () => { + setup(); + + expect(service.apiUrl).toBe(MOCK_API_URL); + }); + + it('getRegistrationById should request registration and map response', async () => { + setup(); + const apiResponse = { + data: registrationData, + meta: { anonymous: true }, + } as RegistrationOverviewResponse; + stubRegistrationNodeMapper(); + jsonApiService.get.mockReturnValue(of(apiResponse)); + + const response = await firstValueFrom(service.getRegistrationById(registryId)); + + expect(jsonApiService.get).toHaveBeenCalledWith(`${MOCK_API_URL}/registrations/${registryId}/`, { + related_counts: 'forks', + }); + expect(response.isAnonymous).toBe(true); + expect(response.registry).toEqual( + expect.objectContaining({ + id: MOCK_REGISTRATION_OVERVIEW_MODEL.id, + associatedProjectId: 'project-1', + providerId: 'osf', + licenseId: 'license-1', + forksCount: 3, + }) + ); + }); + + it('getRegistrationById should default isAnonymous to false when meta is missing', async () => { + setup(); + stubRegistrationNodeMapper(); + jsonApiService.get.mockReturnValue(of({ data: registrationData } as RegistrationOverviewResponse)); + + const response = await firstValueFrom(service.getRegistrationById(registryId)); + + expect(response.isAnonymous).toBe(false); + }); + + it('getInstitutions should request institutions and map response', async () => { + setup(); + const apiResponse = { data: [] } as unknown as InstitutionsJsonApiResponse; + const mappedInstitutions = [MOCK_INSTITUTION] as unknown as Institution[]; + const mapperSpy = vi.spyOn(InstitutionsMapper, 'fromInstitutionsResponse').mockReturnValue(mappedInstitutions); + jsonApiService.get.mockReturnValue(of(apiResponse)); + + const response = await firstValueFrom(service.getInstitutions(registryId)); + + expect(jsonApiService.get).toHaveBeenCalledWith(`${MOCK_API_URL}/registrations/${registryId}/institutions/`, { + 'page[size]': 100, + }); + expect(mapperSpy).toHaveBeenCalledWith(apiResponse); + expect(response).toEqual(mappedInstitutions); + }); + + it('getRegistryIdentifiers should request identifiers and map response', async () => { + setup(); + const apiResponse = { data: [] } as unknown as IdentifiersResponseJsonApi; + const mapperSpy = vi.spyOn(IdentifiersMapper, 'fromJsonApi').mockReturnValue([MOCK_PROJECT_IDENTIFIERS]); + jsonApiService.get.mockReturnValue(of(apiResponse)); + + const response = await firstValueFrom(service.getRegistryIdentifiers(registryId)); + + expect(jsonApiService.get).toHaveBeenCalledWith(`${MOCK_API_URL}/registrations/${registryId}/identifiers/`); + expect(mapperSpy).toHaveBeenCalledWith(apiResponse); + expect(response).toEqual([MOCK_PROJECT_IDENTIFIERS]); + }); + + it('getSchemaBlocks should append schema_blocks path without query string', async () => { + setup(); + const schemaLink = 'https://api.test/v2/schemas/schema-1/'; + const apiResponse = { data: [] } as unknown as SchemaBlocksResponseJsonApi; + const mapperSpy = vi.spyOn(PageSchemaMapper, 'fromSchemaBlocksResponse').mockReturnValue(MOCK_PAGES_SCHEMA); + jsonApiService.get.mockReturnValue(of(apiResponse)); + + const response = await firstValueFrom(service.getSchemaBlocks(schemaLink)); + + expect(jsonApiService.get).toHaveBeenCalledWith(`${schemaLink}schema_blocks/`, { + 'page[size]': 200, + page: 1, + }); + expect(mapperSpy).toHaveBeenCalledWith(apiResponse); + expect(response).toEqual(MOCK_PAGES_SCHEMA); + }); + + it('getSchemaBlocks should preserve query string when schema link has params', async () => { + setup(); + const schemaLink = 'https://api.test/v2/schemas/schema-1/?version=2'; + const apiResponse = { data: [] } as unknown as SchemaBlocksResponseJsonApi; + vi.spyOn(PageSchemaMapper, 'fromSchemaBlocksResponse').mockReturnValue(MOCK_PAGES_SCHEMA); + jsonApiService.get.mockReturnValue(of(apiResponse)); + + await firstValueFrom(service.getSchemaBlocks(schemaLink)); + + expect(jsonApiService.get).toHaveBeenCalledWith('https://api.test/v2/schemas/schema-1/schema_blocks/?version=2', { + 'page[size]': 200, + page: 1, + }); + }); + + it('getSchemaResponses should request schema responses and map each item', async () => { + setup(); + const apiItem = { id: 'schema-response-1' }; + const apiResponse = { data: [apiItem] } as unknown as SchemaResponsesJsonApi; + const mapperSpy = vi.spyOn(RegistrationMapper, 'fromSchemaResponse').mockReturnValue(schemaResponse); + jsonApiService.get.mockReturnValue(of(apiResponse)); + + const response = await firstValueFrom(service.getSchemaResponses(registryId)); + + expect(jsonApiService.get).toHaveBeenCalledWith(`${MOCK_API_URL}/registrations/${registryId}/schema_responses/`); + expect(mapperSpy).toHaveBeenCalledWith(apiItem); + expect(response).toEqual([schemaResponse]); + }); + + it('getRegistryReviewActions should request actions and map each item', async () => { + setup(); + const apiItem = { id: 'action-1' }; + const apiResponse = { data: [apiItem] } as unknown as ReviewActionsResponseJsonApi; + const mappedAction = REVIEW_ACTION_MOCK as ReviewAction; + const mapperSpy = vi.spyOn(RegistryModerationMapper, 'fromActionResponse').mockReturnValue(mappedAction); + jsonApiService.get.mockReturnValue(of(apiResponse)); + + const response = await firstValueFrom(service.getRegistryReviewActions(registryId)); + + expect(jsonApiService.get).toHaveBeenCalledWith(`${MOCK_API_URL}/registrations/${registryId}/actions/`); + expect(mapperSpy).toHaveBeenCalledWith(apiItem); + expect(response).toEqual([mappedAction]); + }); + + it('createSchemaResponse should post payload and map response', async () => { + setup(); + const apiResponse = { data: { id: 'schema-response-1' } } as unknown as SchemaResponseJsonApi; + const mapperSpy = vi.spyOn(RegistrationMapper, 'fromSchemaResponse').mockReturnValue(schemaResponse); + jsonApiService.post.mockReturnValue(of(apiResponse)); + + const response = await firstValueFrom(service.createSchemaResponse(registryId)); + + expect(jsonApiService.post).toHaveBeenCalledWith(`${MOCK_API_URL}/schema_responses/`, { + data: { + type: 'schema_responses', + relationships: { + registration: { + data: { + type: 'registrations', + id: registryId, + }, + }, + }, + }, + }); + expect(mapperSpy).toHaveBeenCalledWith(apiResponse.data); + expect(response).toEqual(schemaResponse); + }); + + it('withdrawRegistration should patch registration and map response', async () => { + setup(); + const justification = 'Withdrawing for testing'; + stubRegistrationNodeMapper(); + jsonApiService.patch.mockReturnValue(of(registrationData)); + + const response = await firstValueFrom(service.withdrawRegistration(registryId, justification)); + + expect(jsonApiService.patch).toHaveBeenCalledWith(`${MOCK_API_URL}/registrations/${registryId}/`, { + data: { + id: registryId, + attributes: { + withdrawal_justification: justification, + pending_withdrawal: true, + }, + relationships: {}, + type: 'registrations', + }, + }); + expect(response).toEqual( + expect.objectContaining({ + id: MOCK_REGISTRATION_OVERVIEW_MODEL.id, + associatedProjectId: 'project-1', + forksCount: 3, + }) + ); + }); + + it('makePublic should patch registration public flag and map response', async () => { + setup(); + stubRegistrationNodeMapper(); + jsonApiService.patch.mockReturnValue(of(registrationData)); + + const response = await firstValueFrom(service.makePublic(registryId)); + + expect(jsonApiService.patch).toHaveBeenCalledWith(`${MOCK_API_URL}/registrations/${registryId}/`, { + data: { + id: registryId, + attributes: { + public: true, + }, + relationships: {}, + type: 'registrations', + }, + }); + expect(response).toEqual( + expect.objectContaining({ + id: MOCK_REGISTRATION_OVERVIEW_MODEL.id, + providerId: 'osf', + forksCount: 3, + }) + ); + }); + + it('submitDecision should post review action for registrations', async () => { + setup(); + const payload: ReviewActionPayload = { + targetId: registryId, + action: 'accept_submission', + comment: 'Looks good', + }; + const mappedParams = { + data: { type: 'review_actions' }, + } as ReviewActionPayloadJsonApi; + const mapperSpy = vi.spyOn(ReviewActionsMapper, 'toReviewActionPayloadJsonApi').mockReturnValue(mappedParams); + jsonApiService.post.mockReturnValue(of(undefined)); + + const response = await firstValueFrom(service.submitDecision(payload, false)); + + expect(mapperSpy).toHaveBeenCalledWith(payload, 'review_actions', 'registrations'); + expect(jsonApiService.post).toHaveBeenCalledWith( + `${MOCK_API_URL}/registrations/${registryId}/actions/`, + mappedParams + ); + expect(response).toBeUndefined(); + }); + + it('submitDecision should post schema response action for revisions', async () => { + setup(); + const payload: ReviewActionPayload = { + targetId: 'schema-response-1', + action: 'approve', + comment: 'Approved', + }; + const mappedParams = { + data: { type: 'schema_response_actions' }, + } as ReviewActionPayloadJsonApi; + const mapperSpy = vi.spyOn(ReviewActionsMapper, 'toReviewActionPayloadJsonApi').mockReturnValue(mappedParams); + jsonApiService.post.mockReturnValue(of(undefined)); + + const response = await firstValueFrom(service.submitDecision(payload, true)); + + expect(mapperSpy).toHaveBeenCalledWith(payload, 'schema_response_actions', 'schema-responses'); + expect(jsonApiService.post).toHaveBeenCalledWith( + `${MOCK_API_URL}/schema_responses/${payload.targetId}/actions/`, + mappedParams + ); + expect(response).toBeUndefined(); + }); +}); diff --git a/src/app/features/registry/services/registry-overview.service.ts b/src/app/features/registry/services/registry-overview.service.ts index ee8bfeb18..de2509775 100644 --- a/src/app/features/registry/services/registry-overview.service.ts +++ b/src/app/features/registry/services/registry-overview.service.ts @@ -7,15 +7,12 @@ import { RegistryModerationMapper } from '@osf/features/moderation/mappers'; import { ReviewAction, ReviewActionsResponseJsonApi } from '@osf/features/moderation/models'; import { IdentifiersMapper } from '@osf/shared/mappers/identifiers.mapper'; import { InstitutionsMapper } from '@osf/shared/mappers/institutions'; -import { LicensesMapper } from '@osf/shared/mappers/licenses.mapper'; import { PageSchemaMapper, RegistrationMapper } from '@osf/shared/mappers/registration'; import { ReviewActionsMapper } from '@osf/shared/mappers/review-actions.mapper'; import { IdentifierModel } from '@osf/shared/models/identifiers/identifier.model'; import { IdentifiersResponseJsonApi } from '@osf/shared/models/identifiers/identifier-json-api.model'; import { InstitutionsJsonApiResponse } from '@osf/shared/models/institutions/institution-json-api.model'; import { Institution } from '@osf/shared/models/institutions/institutions.model'; -import { LicenseModel } from '@osf/shared/models/license/license.model'; -import { LicenseResponseJsonApi } from '@osf/shared/models/license/licenses-json-api.model'; import { PageSchema } from '@osf/shared/models/registration/page-schema.model'; import { SchemaBlocksResponseJsonApi } from '@osf/shared/models/registration/schema-blocks-json-api.model'; import { SchemaResponse } from '@osf/shared/models/registration/schema-response.model'; @@ -70,12 +67,6 @@ export class RegistryOverviewService { .pipe(map((response) => IdentifiersMapper.fromJsonApi(response))); } - getRegistryLicense(licenseId: string): Observable { - return this.jsonApiService - .get(`${this.apiUrl}/licenses/${licenseId}/`) - .pipe(map((response) => LicensesMapper.fromLicenseDataJsonApi(response.data))); - } - getSchemaBlocks(schemaLink: string): Observable { const params = { 'page[size]': 200, diff --git a/src/app/features/registry/store/registry/registry.state.ts b/src/app/features/registry/store/registry/registry.state.ts index 3674f4989..ab61eee91 100644 --- a/src/app/features/registry/store/registry/registry.state.ts +++ b/src/app/features/registry/store/registry/registry.state.ts @@ -8,6 +8,7 @@ import { inject, Injectable } from '@angular/core'; import { CurrentResourceType } from '@osf/shared/enums/resource-type.enum'; import { handleSectionError } from '@osf/shared/helpers/state-error.handler'; import { DuplicatesService } from '@osf/shared/services/duplicates.service'; +import { LicensesService } from '@osf/shared/services/licenses.service'; import { GetRegistryProvider } from '@osf/shared/stores/registration-provider'; import { RegistryOverviewService } from '../../services'; @@ -39,6 +40,7 @@ import { REGISTRY_DEFAULTS, RegistryStateModel } from './registry.model'; export class RegistryState { private readonly registryOverviewService = inject(RegistryOverviewService); private readonly duplicatesService = inject(DuplicatesService); + private readonly licensesService = inject(LicensesService); @Action(GetRegistryById) getRegistryById(ctx: StateContext, action: GetRegistryById) { @@ -164,7 +166,7 @@ export class RegistryState { }, }); - return this.registryOverviewService.getRegistryLicense(action.licenseId).pipe( + return this.licensesService.getLicenseById(action.licenseId).pipe( tap((license) => { ctx.patchState({ license: { diff --git a/src/app/shared/components/license/license.component.html b/src/app/shared/components/license/license.component.html index 3ad93cb67..8181970c4 100644 --- a/src/app/shared/components/license/license.component.html +++ b/src/app/shared/components/license/license.component.html @@ -1,58 +1,67 @@ -@if (selectedLicense()) { + +@let license = selectedLicense(); + +@if (license) { - @if (selectedLicense()!.requiredFields.length) { + @if (hasRequiredFields()) {
- + + + @if ( + licenseForm.controls.year.invalid && (licenseForm.controls.year.touched || licenseForm.controls.year.dirty) + ) { + + {{ requiredMessage | translate }} + + }
}

- +

- @if (selectedLicense()!.requiredFields.length && showInternalButtons()) { + @if (hasRequiredFields() && showInternalButtons()) {
} diff --git a/src/app/shared/components/license/license.component.spec.ts b/src/app/shared/components/license/license.component.spec.ts index c6d3438a3..c7b59ce96 100644 --- a/src/app/shared/components/license/license.component.spec.ts +++ b/src/app/shared/components/license/license.component.spec.ts @@ -2,7 +2,8 @@ import { MockComponents } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { LicenseModel, LicenseOptions } from '@shared/models/license/license.model'; +import { INPUT_VALIDATION_MESSAGES } from '@osf/shared/constants/input-validation-messages.const'; +import { LicenseModel, LicenseOptions, LicenseState } from '@osf/shared/models/license/license.model'; import { MOCK_LICENSE } from '@testing/mocks/license.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; @@ -16,23 +17,30 @@ describe('LicenseComponent', () => { let component: LicenseComponent; let fixture: ComponentFixture; - const mockLicenses: LicenseModel[] = [ - { - ...MOCK_LICENSE, - id: 'license-1', - name: 'MIT License', - requiredFields: [], - text: 'MIT License text', - }, - MOCK_LICENSE, - ]; + const licenseWithoutFields: LicenseModel = { + ...MOCK_LICENSE, + id: 'license-1', + name: 'MIT License', + requiredFields: [], + text: 'MIT License text', + }; + + const licenseWithFields: LicenseModel = MOCK_LICENSE; + + const mockLicenses: LicenseModel[] = [licenseWithoutFields, licenseWithFields]; const mockLicenseOptions: LicenseOptions = { year: '2024', copyrightHolders: 'John Doe', }; - beforeEach(() => { + interface SetupOverrides { + selectedLicenseId?: string | null; + selectedLicenseOptions?: LicenseOptions | null; + showInternalButtons?: boolean; + } + + function setup(overrides: SetupOverrides = {}) { TestBed.configureTestingModule({ imports: [LicenseComponent, ...MockComponents(TextInputComponent, TruncatedTextComponent)], providers: [provideOSFCore()], @@ -42,97 +50,105 @@ describe('LicenseComponent', () => { component = fixture.componentInstance; fixture.componentRef.setInput('licenses', mockLicenses); - fixture.componentRef.setInput('selectedLicenseId', null); - fixture.componentRef.setInput('selectedLicenseOptions', null); - fixture.componentRef.setInput('isSubmitting', false); - fixture.componentRef.setInput('showInternalButtons', true); - fixture.componentRef.setInput('fullWidthSelect', false); - + fixture.componentRef.setInput('selectedLicenseId', overrides.selectedLicenseId ?? null); + fixture.componentRef.setInput('selectedLicenseOptions', overrides.selectedLicenseOptions ?? null); + fixture.componentRef.setInput('showInternalButtons', overrides.showInternalButtons ?? true); fixture.detectChanges(); - }); + } - it('should create', () => { - expect(component).toBeTruthy(); - }); + it('should initialize license form with current year and empty copyright holders', () => { + setup(); - it('should set licenses input', () => { - expect(component.licenses()).toEqual(mockLicenses); + expect(component.licenseForm.controls.year.value).toBe(new Date().getFullYear().toString()); + expect(component.licenseForm.controls.copyrightHolders.value).toBe(''); }); - it('should set selectedLicenseId input', () => { - expect(component.selectedLicenseId()).toBe(null); - }); + it('should sync selectedLicense from selectedLicenseId', () => { + setup({ selectedLicenseId: licenseWithoutFields.id }); - it('should set selectedLicenseOptions input', () => { - expect(component.selectedLicenseOptions()).toBe(null); + expect(component.selectedLicense()).toEqual(licenseWithoutFields); }); - it('should set isSubmitting input', () => { - expect(component.isSubmitting()).toBe(false); - }); + it('should clear selectedLicense when selectedLicenseId is not in licenses', () => { + setup({ selectedLicenseId: 'missing-id' }); - it('should set showInternalButtons input', () => { - expect(component.showInternalButtons()).toBe(true); + expect(component.selectedLicense()).toBeNull(); }); - it('should set fullWidthSelect input', () => { - expect(component.fullWidthSelect()).toBe(false); - }); + it('should emit selectLicense and keep pending selection until id updates', () => { + setup(); + const emitSpy = vi.spyOn(component.selectLicense, 'emit'); + + component.onSelectLicense(licenseWithFields); - it('should initialize license form with current year', () => { - const currentYear = new Date().getFullYear().toString(); - expect(component.licenseForm.get('year')?.value).toBe(currentYear); + expect(emitSpy).toHaveBeenCalledWith(licenseWithFields); + expect(component.selectedLicense()).toEqual(licenseWithFields); }); - it('should initialize license form with empty copyright holders', () => { - expect(component.licenseForm.get('copyrightHolders')?.value).toBe(''); + it('should patch form when selectedLicenseOptions change', () => { + setup({ selectedLicenseOptions: mockLicenseOptions }); + + expect(component.licenseForm.controls.year.value).toBe('2024'); + expect(component.licenseForm.controls.copyrightHolders.value).toBe('John Doe'); }); - it('should emit selectLicense when license without required fields is selected', () => { - const emitSpy = vi.spyOn(component.selectLicense, 'emit'); - const license = mockLicenses[0]; + it('should emit licenseStateChange with form values', () => { + setup({ selectedLicenseOptions: mockLicenseOptions }); + const states: LicenseState[] = []; - component.onSelectLicense(license); + component.licenseStateChange.subscribe((state) => states.push(state)); + component.licenseForm.patchValue({ + year: '2025', + copyrightHolders: 'Jane Doe', + }); - expect(emitSpy).toHaveBeenCalledWith(license); + expect(states.at(-1)).toEqual({ + licenseOptions: { + year: '2025', + copyrightHolders: 'Jane Doe', + }, + formInvalid: false, + }); }); it('should emit createLicense when save is called with valid form', () => { + setup({ selectedLicenseId: licenseWithFields.id }); const emitSpy = vi.spyOn(component.createLicense, 'emit'); - component.selectedLicense.set(mockLicenses[1]); - - component.licenseForm.patchValue({ - year: '2024', - copyrightHolders: 'John Doe', - }); - + component.licenseForm.patchValue(mockLicenseOptions); component.saveLicense(); expect(emitSpy).toHaveBeenCalledWith({ - id: MOCK_LICENSE.id, - licenseOptions: { - year: '2024', - copyrightHolders: 'John Doe', - }, + id: licenseWithFields.id, + licenseOptions: mockLicenseOptions, }); }); it('should not emit createLicense when form is invalid', () => { + setup({ selectedLicenseId: licenseWithFields.id }); const emitSpy = vi.spyOn(component.createLicense, 'emit'); - component.selectedLicense.set(mockLicenses[1]); component.licenseForm.patchValue({ year: '', copyrightHolders: '', }); + component.saveLicense(); + + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('should not emit createLicense when no license is selected', () => { + setup(); + const emitSpy = vi.spyOn(component.createLicense, 'emit'); + component.licenseForm.patchValue(mockLicenseOptions); component.saveLicense(); expect(emitSpy).not.toHaveBeenCalled(); }); - it('should reset form when cancel is called', () => { + it('should reset form to defaults when cancel is called without selected options', () => { + setup(); component.licenseForm.patchValue({ year: '2023', copyrightHolders: 'Test Holder', @@ -140,15 +156,82 @@ describe('LicenseComponent', () => { component.cancel(); - expect(component.licenseForm.get('year')?.value).toBe(''); - expect(component.licenseForm.get('copyrightHolders')?.value).toBe(''); + expect(component.licenseForm.controls.year.value).toBe(new Date().getFullYear().toString()); + expect(component.licenseForm.controls.copyrightHolders.value).toBe(''); }); - it('should update form when selectedLicenseOptions changes', () => { - fixture.componentRef.setInput('selectedLicenseOptions', mockLicenseOptions); + it('should restore selectedLicenseOptions when cancel is called', () => { + setup({ selectedLicenseOptions: mockLicenseOptions }); + component.licenseForm.patchValue({ + year: '2020', + copyrightHolders: 'Changed', + }); + + component.cancel(); + + expect(component.licenseForm.controls.year.value).toBe('2024'); + expect(component.licenseForm.controls.copyrightHolders.value).toBe('John Doe'); + }); + + it('should toggle saveButtonDisabled based on whether form matches selected options', () => { + setup({ + selectedLicenseId: licenseWithFields.id, + selectedLicenseOptions: mockLicenseOptions, + }); + + expect(component.saveButtonDisabled()).toBe(true); + + component.licenseForm.patchValue({ + year: '2025', + copyrightHolders: 'Jane Doe', + }); + fixture.detectChanges(); + + expect(component.saveButtonDisabled()).toBe(false); + }); + + it('should keep save enabled and form valid when license has no required fields', () => { + setup({ + selectedLicenseId: licenseWithoutFields.id, + selectedLicenseOptions: mockLicenseOptions, + }); + + expect(component.hasRequiredFields()).toBe(false); + expect(component.licenseForm.valid).toBe(true); + expect(component.saveButtonDisabled()).toBe(false); + }); + + it('should require year and copyright holders when license has required fields', () => { + setup({ selectedLicenseId: licenseWithFields.id }); + + expect(component.hasRequiredFields()).toBe(true); + expect(component.licenseForm.invalid).toBe(true); + + component.licenseForm.patchValue(mockLicenseOptions); + + expect(component.licenseForm.valid).toBe(true); + }); + + it('should render internal buttons only when showInternalButtons is true', () => { + setup({ selectedLicenseId: licenseWithFields.id }); + expect(fixture.nativeElement.querySelectorAll('p-button').length).toBe(2); + + setup({ + selectedLicenseId: licenseWithFields.id, + showInternalButtons: false, + }); + expect(fixture.nativeElement.querySelectorAll('p-button').length).toBe(0); + }); + + it('should show year required error when year control is invalid and dirty', () => { + setup({ selectedLicenseId: licenseWithFields.id }); + + component.licenseForm.controls.year.setValue(''); + component.licenseForm.controls.year.markAsDirty(); fixture.detectChanges(); - expect(component.licenseForm.get('year')?.value).toBe('2024'); - expect(component.licenseForm.get('copyrightHolders')?.value).toBe('John Doe'); + const message = fixture.nativeElement.querySelector('p-message'); + expect(message).toBeTruthy(); + expect(message.textContent).toContain(INPUT_VALIDATION_MESSAGES.required); }); }); diff --git a/src/app/shared/components/license/license.component.ts b/src/app/shared/components/license/license.component.ts index 6d8823827..4a901d2b4 100644 --- a/src/app/shared/components/license/license.component.ts +++ b/src/app/shared/components/license/license.component.ts @@ -3,18 +3,22 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { DatePicker } from 'primeng/datepicker'; import { Divider } from 'primeng/divider'; +import { Message } from 'primeng/message'; import { Select } from 'primeng/select'; -import { ChangeDetectionStrategy, Component, effect, input, model, output, signal } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; +import { map, merge, startWith } from 'rxjs'; + +import { ChangeDetectionStrategy, Component, computed, effect, input, output, signal, untracked } from '@angular/core'; +import { outputFromObservable, toSignal } from '@angular/core/rxjs-interop'; import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { InputLimits } from '@osf/shared/constants/input-limits.const'; +import { INPUT_VALIDATION_MESSAGES } from '@osf/shared/constants/input-validation-messages.const'; import { CustomValidators } from '@osf/shared/helpers/custom-form-validators.helper'; import { StringOrNullOrUndefined } from '@osf/shared/helpers/types.helper'; +import { LicenseDetails, LicenseModel, LicenseOptions, LicenseState } from '@osf/shared/models/license/license.model'; import { LicenseForm } from '@osf/shared/models/license/license-form.model'; -import { LicenseModel, LicenseOptions } from '@shared/models/license/license.model'; -import { InterpolatePipe } from '@shared/pipes/interpolate.pipe'; +import { InterpolatePipe } from '@osf/shared/pipes/interpolate.pipe'; import { TextInputComponent } from '../text-input/text-input.component'; import { TruncatedTextComponent } from '../truncated-text/truncated-text.component'; @@ -32,71 +36,97 @@ import { TruncatedTextComponent } from '../truncated-text/truncated-text.compone Button, TruncatedTextComponent, InterpolatePipe, + Message, ], templateUrl: './license.component.html', styleUrl: './license.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class LicenseComponent { - selectedLicenseId = input(null); - selectedLicenseOptions = input(null); - licenses = input.required(); - isSubmitting = input(false); - showInternalButtons = input(true); - fullWidthSelect = input(false); - appendTo = input(null); - selectedLicense = model(null); - createLicense = output<{ id: string; licenseOptions: LicenseOptions }>(); - selectLicense = output(); - inputLimits = InputLimits; - saveButtonDisabled = signal(false); - - currentYear = new Date(); - licenseForm = new FormGroup({ - year: new FormControl(this.currentYear.getFullYear().toString(), { - nonNullable: true, - validators: [CustomValidators.requiredTrimmed()], - }), - copyrightHolders: new FormControl('', { - nonNullable: true, - validators: [CustomValidators.requiredTrimmed()], - }), + readonly selectedLicenseId = input(null); + readonly selectedLicenseOptions = input(null); + readonly licenses = input.required(); + readonly showInternalButtons = input(true); + readonly appendTo = input(null); + readonly createLicense = output(); + readonly selectLicense = output(); + + readonly inputLimits = InputLimits; + readonly requiredMessage = INPUT_VALIDATION_MESSAGES.required; + readonly today = new Date(); + readonly yearInputId = `license-year-${Math.random().toString(36).substring(2, 15)}`; + + private readonly pendingLicense = signal(null); + private appliedOptions: LicenseOptions | null = null; + + readonly selectedLicense = computed(() => { + const licenses = this.licenses(); + const pending = this.pendingLicense(); + + if (pending) { + return licenses.find((license) => license.id === pending.id) ?? pending; + } + + return licenses.find((license) => license.id === this.selectedLicenseId()) ?? null; + }); + + readonly hasRequiredFields = computed(() => !!this.selectedLicense()?.requiredFields.length); + + readonly licenseForm = new FormGroup({ + year: new FormControl(this.today.getFullYear().toString(), { nonNullable: true }), + copyrightHolders: new FormControl('', { nonNullable: true }), + }); + + readonly licenseFormValue = toSignal(this.licenseForm.valueChanges.pipe(map(() => this.licenseForm.getRawValue())), { + initialValue: this.licenseForm.getRawValue(), + }); + readonly licenseFormInvalid = toSignal(this.licenseForm.statusChanges.pipe(map((status) => status === 'INVALID')), { + initialValue: this.licenseForm.invalid, + }); + + readonly licenseStateChange = outputFromObservable( + merge(this.licenseForm.valueChanges, this.licenseForm.statusChanges).pipe( + startWith(null), + map(() => ({ + licenseOptions: this.licenseForm.getRawValue(), + formInvalid: this.licenseForm.invalid, + })) + ) + ); + + readonly saveButtonDisabled = computed(() => { + const baseline = this.selectedLicenseOptions(); + return this.hasRequiredFields() && !!baseline && this.isSameOptions(baseline, this.licenseFormValue()); }); - licenseFormValue = toSignal(this.licenseForm.valueChanges); constructor() { effect(() => { - const license = this.licenses().find((l) => l.id === this.selectedLicenseId()); - this.selectedLicense.set(license || null); + this.selectedLicenseId(); + untracked(() => this.pendingLicense.set(null)); }); effect(() => { - const options = this.selectedLicenseOptions(); - if (this.selectedLicenseOptions()) { - this.licenseForm.patchValue({ - year: options!.year, - copyrightHolders: options!.copyrightHolders, - }); + const options = this.resolveLicenseOptions(this.selectedLicenseOptions()); + if (this.appliedOptions && this.isSameOptions(this.appliedOptions, options)) { + return; } + + this.appliedOptions = options; + untracked(() => this.licenseForm.patchValue(options)); }); effect(() => { - const licenseOptionsInput = this.selectedLicenseOptions(); - const licenseOptionsFormValue = this.licenseFormValue(); - - if (!this.selectedLicense() || !this.selectedLicense()?.requiredFields.length) { - return; - } + const required = this.hasRequiredFields(); - if (JSON.stringify(licenseOptionsInput) === JSON.stringify(licenseOptionsFormValue)) { - this.saveButtonDisabled.set(true); - } else { - this.saveButtonDisabled.set(false); - } + untracked(() => { + this.setRequired(this.licenseForm.controls.year, required); + this.setRequired(this.licenseForm.controls.copyrightHolders, required); + }); }); } onSelectLicense(license: LicenseModel): void { + this.pendingLicense.set(license); this.selectLicense.emit(license); } @@ -104,19 +134,35 @@ export class LicenseComponent { if (this.licenseForm.invalid) { return; } - const selectedLicenseId = this.selectedLicense()!.id; - const model = this.licenseForm.value as LicenseOptions; + const selectedLicense = this.selectedLicense(); + if (!selectedLicense) { + return; + } + this.createLicense.emit({ - id: selectedLicenseId, - licenseOptions: model, + id: selectedLicense.id, + licenseOptions: this.licenseForm.getRawValue(), }); } cancel() { - this.licenseForm.reset({ - year: '', - copyrightHolders: '', - }); + this.licenseForm.reset(this.resolveLicenseOptions(this.selectedLicenseOptions())); + } + + private setRequired(control: FormControl, required: boolean): void { + control.setValidators(required ? [CustomValidators.requiredTrimmed()] : null); + control.updateValueAndValidity(); + } + + private isSameOptions(a: LicenseOptions, b: LicenseOptions): boolean { + return a.year === b.year && a.copyrightHolders === b.copyrightHolders; + } + + private resolveLicenseOptions(options: LicenseOptions | null | undefined): LicenseOptions { + return { + year: options?.year ?? this.today.getFullYear().toString(), + copyrightHolders: options?.copyrightHolders ?? '', + }; } } diff --git a/src/app/shared/models/license/license.model.ts b/src/app/shared/models/license/license.model.ts index c9da318ac..214ccab68 100644 --- a/src/app/shared/models/license/license.model.ts +++ b/src/app/shared/models/license/license.model.ts @@ -11,6 +11,16 @@ export interface LicenseOptions { year: string; } +export interface LicenseDetails { + id: string; + licenseOptions: LicenseOptions; +} + +export interface LicenseState { + licenseOptions: LicenseOptions; + formInvalid: boolean; +} + export interface LicensesOption { copyrightHolders: string[] | null; year: string | null; diff --git a/src/app/shared/services/licenses.service.spec.ts b/src/app/shared/services/licenses.service.spec.ts new file mode 100644 index 000000000..0da4f70bd --- /dev/null +++ b/src/app/shared/services/licenses.service.spec.ts @@ -0,0 +1,129 @@ +import { MockProvider } from 'ng-mocks'; + +import { firstValueFrom, of } from 'rxjs'; + +import { TestBed } from '@angular/core/testing'; + +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { LicensesMapper } from '@osf/shared/mappers/licenses.mapper'; +import { LicenseResponseJsonApi, LicensesResponseJsonApi } from '@osf/shared/models/license/licenses-json-api.model'; +import { JsonApiService } from '@osf/shared/services/json-api.service'; + +import { MOCK_LICENSE } from '@testing/mocks/license.mock'; +import { MOCK_API_URL, provideMockEnvironment } from '@testing/providers/environment.provider.mock'; +import { JsonApiServiceMock, JsonApiServiceMockType } from '@testing/providers/json-api.service.mock'; + +import { LicensesService } from './licenses.service'; + +describe('LicensesService', () => { + let service: LicensesService; + let jsonApiService: JsonApiServiceMockType; + + const defaultProvider = 'osf'; + const apiResponse = { data: [] } as unknown as LicensesResponseJsonApi; + + function setup() { + jsonApiService = JsonApiServiceMock.simple(); + + TestBed.configureTestingModule({ + providers: [ + LicensesService, + MockProvider(JsonApiService, jsonApiService), + provideMockEnvironment({ defaultProvider }), + ], + }); + + service = TestBed.inject(LicensesService); + } + + it('should expose apiUrl from environment', () => { + setup(); + + expect(service.apiUrl).toBe(MOCK_API_URL); + }); + + it('should expose defaultProvider from environment', () => { + setup(); + + expect(service.defaultProvider).toBe(defaultProvider); + }); + + it('getLicenses should request project licenses with sort', async () => { + setup(); + const mapperSpy = vi.spyOn(LicensesMapper, 'fromLicensesResponse').mockReturnValue([MOCK_LICENSE]); + jsonApiService.get.mockReturnValue(of(apiResponse)); + + const response = await firstValueFrom(service.getLicenses(ResourceType.Project)); + + expect(jsonApiService.get).toHaveBeenCalledWith(`${MOCK_API_URL}/licenses/`, { + 'page[size]': 100, + sort: 'name', + }); + expect(mapperSpy).toHaveBeenCalledWith(apiResponse); + expect(response).toEqual([MOCK_LICENSE]); + }); + + it('getLicenses should request registration licenses for provider with sort', async () => { + setup(); + const providerId = 'provider-1'; + const mapperSpy = vi.spyOn(LicensesMapper, 'fromLicensesResponse').mockReturnValue([MOCK_LICENSE]); + jsonApiService.get.mockReturnValue(of(apiResponse)); + + const response = await firstValueFrom(service.getLicenses(ResourceType.Registration, providerId)); + + expect(jsonApiService.get).toHaveBeenCalledWith(`${MOCK_API_URL}/providers/registrations/${providerId}/licenses/`, { + 'page[size]': 100, + sort: 'name', + }); + expect(mapperSpy).toHaveBeenCalledWith(apiResponse); + expect(response).toEqual([MOCK_LICENSE]); + }); + + it('getLicenses should request preprint licenses with default provider and sort', async () => { + setup(); + const mapperSpy = vi.spyOn(LicensesMapper, 'fromLicensesResponse').mockReturnValue([MOCK_LICENSE]); + jsonApiService.get.mockReturnValue(of(apiResponse)); + + const response = await firstValueFrom(service.getLicenses(ResourceType.Preprint)); + + expect(jsonApiService.get).toHaveBeenCalledWith( + `${MOCK_API_URL}/providers/preprints/${defaultProvider}/licenses/`, + { + 'page[size]': 100, + sort: 'name', + } + ); + expect(mapperSpy).toHaveBeenCalledWith(apiResponse); + expect(response).toEqual([MOCK_LICENSE]); + }); + + it('getLicenses should request collection licenses with sort', async () => { + setup(); + const providerId = 'collection-provider'; + const mapperSpy = vi.spyOn(LicensesMapper, 'fromLicensesResponse').mockReturnValue([MOCK_LICENSE]); + jsonApiService.get.mockReturnValue(of(apiResponse)); + + const response = await firstValueFrom(service.getLicenses(ResourceType.Collection, providerId)); + + expect(jsonApiService.get).toHaveBeenCalledWith(`${MOCK_API_URL}/providers/collections/${providerId}/licenses/`, { + 'page[size]': 100, + sort: 'name', + }); + expect(mapperSpy).toHaveBeenCalledWith(apiResponse); + expect(response).toEqual([MOCK_LICENSE]); + }); + + it('getLicenseById should request license and map response', async () => { + setup(); + const licenseId = MOCK_LICENSE.id; + const licenseApiResponse = { data: { id: licenseId } } as LicenseResponseJsonApi; + const mapperSpy = vi.spyOn(LicensesMapper, 'fromLicenseDataJsonApi').mockReturnValue(MOCK_LICENSE); + jsonApiService.get.mockReturnValue(of(licenseApiResponse)); + + const response = await firstValueFrom(service.getLicenseById(licenseId)); + + expect(jsonApiService.get).toHaveBeenCalledWith(`${MOCK_API_URL}/licenses/${licenseId}/`); + expect(mapperSpy).toHaveBeenCalledWith(licenseApiResponse.data); + expect(response).toEqual(MOCK_LICENSE); + }); +}); diff --git a/src/app/shared/services/licenses.service.ts b/src/app/shared/services/licenses.service.ts index 505b546cc..a12b82731 100644 --- a/src/app/shared/services/licenses.service.ts +++ b/src/app/shared/services/licenses.service.ts @@ -1,28 +1,56 @@ import { map, Observable } from 'rxjs'; -import { HttpClient } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { ResourceType } from '../enums/resource-type.enum'; import { LicensesMapper } from '../mappers/licenses.mapper'; import { LicenseModel } from '../models/license/license.model'; -import { LicensesResponseJsonApi } from '../models/license/licenses-json-api.model'; +import { LicenseResponseJsonApi, LicensesResponseJsonApi } from '../models/license/licenses-json-api.model'; + +import { JsonApiService } from './json-api.service'; @Injectable({ providedIn: 'root', }) export class LicensesService { - private readonly http = inject(HttpClient); + private readonly jsonApiService = inject(JsonApiService); private readonly environment = inject(ENVIRONMENT); get apiUrl() { return `${this.environment.apiDomainUrl}/v2`; } - getAllLicenses(): Observable { - return this.http - .get(`${this.apiUrl}/licenses/?page[size]=20`) + get defaultProvider() { + return this.environment.defaultProvider; + } + + private readonly urlMap = new Map([ + [ResourceType.Registration, 'registrations'], + [ResourceType.Preprint, 'preprints'], + [ResourceType.Collection, 'collections'], + ]); + + getLicenses(resourceType: ResourceType, providerId = this.defaultProvider): Observable { + const baseUrl = + resourceType === ResourceType.Project + ? `${this.apiUrl}/licenses/` + : `${this.apiUrl}/providers/${this.urlMap.get(resourceType)}/${providerId}/licenses/`; + + const params: Record = { + 'page[size]': 100, + sort: 'name', + }; + + return this.jsonApiService + .get(baseUrl, params) .pipe(map((licenses) => LicensesMapper.fromLicensesResponse(licenses))); } + + getLicenseById(licenseId: string): Observable { + return this.jsonApiService + .get(`${this.apiUrl}/licenses/${licenseId}/`) + .pipe(map((response) => LicensesMapper.fromLicenseDataJsonApi(response.data))); + } } diff --git a/src/app/shared/stores/licenses/licenses.actions.ts b/src/app/shared/stores/licenses/licenses.actions.ts index cd4727b5c..d5293d4f3 100644 --- a/src/app/shared/stores/licenses/licenses.actions.ts +++ b/src/app/shared/stores/licenses/licenses.actions.ts @@ -1,3 +1,10 @@ -export class LoadAllLicenses { - static readonly type = '[Licenses] Load All'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; + +export class FetchLicenses { + static readonly type = '[Licenses] Fetch Licenses'; + + constructor( + public resourceType: ResourceType, + public providerId?: string + ) {} } diff --git a/src/app/shared/stores/licenses/licenses.state.ts b/src/app/shared/stores/licenses/licenses.state.ts index a246f0841..8927bec52 100644 --- a/src/app/shared/stores/licenses/licenses.state.ts +++ b/src/app/shared/stores/licenses/licenses.state.ts @@ -7,7 +7,7 @@ import { inject, Injectable } from '@angular/core'; import { handleSectionError } from '@osf/shared/helpers/state-error.handler'; import { LicensesService } from '@osf/shared/services/licenses.service'; -import { LoadAllLicenses } from './licenses.actions'; +import { FetchLicenses } from './licenses.actions'; import { LICENSES_STATE_DEFAULTS, LicensesStateModel } from './licenses.model'; @State({ @@ -18,8 +18,8 @@ import { LICENSES_STATE_DEFAULTS, LicensesStateModel } from './licenses.model'; export class LicensesState { private readonly licensesService = inject(LicensesService); - @Action(LoadAllLicenses) - loadAllLicenses(ctx: StateContext) { + @Action(FetchLicenses) + fetchLicenses(ctx: StateContext, { resourceType, providerId }: FetchLicenses) { ctx.patchState({ licenses: { data: [], @@ -28,7 +28,7 @@ export class LicensesState { }, }); - return this.licensesService.getAllLicenses().pipe( + return this.licensesService.getLicenses(resourceType, providerId).pipe( tap((data) => { ctx.patchState({ licenses: {