diff --git a/src/app/features/collections/components/add-to-collection/add-to-collection.component.html b/src/app/features/collections/components/add-to-collection/add-to-collection.component.html index d76299fba..41cf077d5 100644 --- a/src/app/features/collections/components/add-to-collection/add-to-collection.component.html +++ b/src/app/features/collections/components/add-to-collection/add-to-collection.component.html @@ -48,11 +48,7 @@

{{ collectionProvider()? [targetStepValue]="AddToCollectionSteps.CollectionMetadata" [isDisabled]="isCollectionMetadataDisabled()" [primaryCollectionId]="primaryCollectionId()" - [isCedarMode]="isCedarMode()" - [cedarTemplate]="requiredMetadataTemplate()" - [existingCedarRecord]="existingCedarRecord()" (metadataSaved)="handleCollectionMetadataSaved($event)" - (cedarDataSaved)="handleCedarDataSaved($event)" (stepChange)="handleChangeStep($event)" /> diff --git a/src/app/features/collections/components/add-to-collection/add-to-collection.component.spec.ts b/src/app/features/collections/components/add-to-collection/add-to-collection.component.spec.ts index b7c9645b7..5788553d0 100644 --- a/src/app/features/collections/components/add-to-collection/add-to-collection.component.spec.ts +++ b/src/app/features/collections/components/add-to-collection/add-to-collection.component.spec.ts @@ -1,47 +1,137 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; +import { Subject } from 'rxjs'; + +import { Mock } from 'vitest'; + +import { PLATFORM_ID } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormGroup } from '@angular/forms'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute, provideRouter, Router } from '@angular/router'; import { UserSelectors } from '@core/store/user'; -import { CollectionMetadataStepComponent } from '@osf/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component'; -import { ProjectContributorsStepComponent } from '@osf/features/collections/components/add-to-collection/project-contributors-step/project-contributors-step.component'; -import { ProjectMetadataStepComponent } from '@osf/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component'; -import { SelectProjectStepComponent } from '@osf/features/collections/components/add-to-collection/select-project-step/select-project-step.component'; import { AddToCollectionSteps } from '@osf/features/collections/enums'; -import { CedarRecordDataBinding } from '@osf/features/metadata/models'; -import { MetadataSelectors } from '@osf/features/metadata/store'; -import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; -import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; -import { ToastService } from '@osf/shared/services/toast.service'; -import { CollectionsSelectors } from '@shared/stores/collections'; -import { ProjectsSelectors } from '@shared/stores/projects/projects.selectors'; +import { + AddToCollectionSelectors, + ClearAddToCollectionState, + GetCurrentCollectionSubmission, + UpdateCollectionSubmission, +} from '@osf/features/collections/store/add-to-collection'; +import { LoadingSpinnerComponent } from '@shared/components/loading-spinner/loading-spinner.component'; +import { CollectionSubmissionReviewState } from '@shared/enums/collection-submission-review-state.enum'; +import { CollectionProjectSubmission, CollectionProvider } from '@shared/models/collections/collections.model'; +import { BrandService } from '@shared/services/brand.service'; +import { CustomDialogService } from '@shared/services/custom-dialog.service'; +import { HeaderStyleService } from '@shared/services/header-style.service'; +import { LoaderService } from '@shared/services/loader.service'; +import { ToastService } from '@shared/services/toast.service'; +import { CollectionsSelectors, GetCollectionProvider } from '@shared/stores/collections'; +import { ProjectsSelectors, SetSelectedProject } from '@shared/stores/projects'; +import { MOCK_COLLECTION_SUBMISSION_1 } from '@testing/mocks/collections-submissions.mock'; import { MOCK_USER } from '@testing/mocks/data.mock'; import { MOCK_PROJECT } from '@testing/mocks/project.mock'; -import { MOCK_PROVIDER } from '@testing/mocks/provider.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; -import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; +import { BrandServiceMock, BrandServiceMockType } from '@testing/providers/brand-service.mock'; +import { + CustomDialogServiceMockBuilder, + CustomDialogServiceMockType, +} from '@testing/providers/custom-dialog-provider.mock'; +import { HeaderStyleServiceMock, HeaderStyleServiceMockType } from '@testing/providers/header-style-service.mock'; +import { LoaderServiceMock } from '@testing/providers/loader-service.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; +import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; +import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; +import { AddToCollectionConfirmationDialogComponent } from './add-to-collection-confirmation-dialog/add-to-collection-confirmation-dialog.component'; +import { CollectionMetadataStepComponent } from './collection-metadata-step/collection-metadata-step.component'; +import { ProjectContributorsStepComponent } from './project-contributors-step/project-contributors-step.component'; +import { ProjectMetadataStepComponent } from './project-metadata-step/project-metadata-step.component'; +import { SelectProjectStepComponent } from './select-project-step/select-project-step.component'; import { AddToCollectionComponent } from './add-to-collection.component'; +const PROVIDER_ID = 'provider-1'; + +function createMockCollectionProvider(overrides: Partial = {}): CollectionProvider { + return { + id: PROVIDER_ID, + type: 'collection-providers', + name: 'Provider', + description: '', + domain: 'osf.io', + advisoryBoard: '', + allowCommenting: false, + allowSubmissions: true, + domainRedirectEnabled: false, + emailSupport: null, + example: null, + facebookAppId: null, + footerLinks: '', + permissions: [], + reviewsWorkflow: '', + sharePublishType: '', + shareSource: '', + assets: {}, + primaryCollection: { id: 'col-1', type: 'collections' }, + brand: null, + ...overrides, + } as CollectionProvider; +} + +const defaultSignals: SignalOverride[] = [ + { selector: CollectionsSelectors.getCollectionProviderLoading, value: false }, + { selector: CollectionsSelectors.getCollectionProvider, value: null }, + { selector: ProjectsSelectors.getSelectedProject, value: null }, + { selector: UserSelectors.getCurrentUser, value: MOCK_USER }, + { selector: AddToCollectionSelectors.getCurrentCollectionSubmission, value: null }, +]; + describe('AddToCollectionComponent', () => { let component: AddToCollectionComponent; let fixture: ComponentFixture; - let mockRouter: ReturnType; - let mockActivatedRoute: ReturnType; - let mockCustomDialogService: ReturnType; + let store: Store; + let routerMock: RouterMockType; + let customDialogMock: CustomDialogServiceMockType; + let dialogCloseSubject: Subject; + let brandServiceMock: BrandServiceMockType; + let headerStyleServiceMock: HeaderStyleServiceMockType; + let loaderServiceMock: LoaderServiceMock; + let toastServiceMock: ToastServiceMockType; - const mockCollectionProvider = MOCK_PROVIDER; + function setup( + options: { + routeParams?: Record; + hasParent?: boolean; + selectorOverrides?: SignalOverride[]; + platformId?: string; + } = {} + ) { + const routeBuilder = ActivatedRouteMockBuilder.create().withParams( + options.routeParams ?? { providerId: PROVIDER_ID } + ); + if (options.hasParent === false) { + routeBuilder.withNoParent(); + } + const mockRoute = routeBuilder.build(); + routerMock = RouterMockBuilder.create().withUrl('/collections/add').build(); + dialogCloseSubject = new Subject(); + customDialogMock = CustomDialogServiceMockBuilder.create() + .withOpen( + vi.fn().mockReturnValue({ + onClose: dialogCloseSubject.asObservable(), + close: vi.fn(), + }) + ) + .build(); + brandServiceMock = BrandServiceMock.simple(); + headerStyleServiceMock = HeaderStyleServiceMock.simple(); + loaderServiceMock = new LoaderServiceMock(); + toastServiceMock = ToastServiceMock.simple(); - beforeEach(() => { - mockRouter = RouterMockBuilder.create().build(); - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: null }).build(); - mockCustomDialogService = CustomDialogServiceMockBuilder.create().build(); + const signals = mergeSignalOverrides(defaultSignals, options.selectorOverrides); TestBed.configureTestingModule({ imports: [ @@ -56,109 +146,253 @@ describe('AddToCollectionComponent', () => { ], providers: [ provideOSFCore(), - MockProvider(ActivatedRoute, mockActivatedRoute), - MockProvider(Router, mockRouter), - MockProvider(CustomDialogService, mockCustomDialogService), - MockProvider(ToastService), - provideMockStore({ - signals: [ - { selector: CollectionsSelectors.getCollectionProviderLoading, value: false }, - { selector: CollectionsSelectors.getCollectionProvider, value: mockCollectionProvider }, - { selector: CollectionsSelectors.getRequiredMetadataTemplate, value: null }, - { selector: ProjectsSelectors.getSelectedProject, value: MOCK_PROJECT }, - { selector: UserSelectors.getCurrentUser, value: MOCK_USER }, - { selector: MetadataSelectors.getCedarRecords, value: [] }, - ], - }), + provideRouter([]), + MockProvider(ActivatedRoute, mockRoute), + MockProvider(Router, routerMock), + MockProvider(CustomDialogService, customDialogMock), + MockProvider(BrandService, brandServiceMock), + MockProvider(HeaderStyleService, headerStyleServiceMock), + MockProvider(LoaderService, loaderServiceMock), + MockProvider(ToastService, toastServiceMock), + MockProvider(PLATFORM_ID, options.platformId ?? 'browser'), + provideMockStore({ signals }), ], }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(AddToCollectionComponent); component = fixture.componentInstance; fixture.detectChanges(); - }); + } it('should create', () => { + setup(); expect(component).toBeTruthy(); }); - it('should initialize with default values', () => { - expect(component.stepperActiveValue()).toBe(AddToCollectionSteps.SelectProject); - expect(component.projectMetadataSaved()).toBe(false); - expect(component.projectContributorsSaved()).toBe(false); - expect(component.collectionMetadataSaved()).toBe(false); - expect(component.allowNavigation()).toBe(false); + it('should navigate to not-found when providerId is missing', () => { + setup({ routeParams: {} }); + expect(routerMock.navigate).toHaveBeenCalledWith(['/not-found']); }); - it('should handle project selection', () => { - component.handleProjectSelected(); + it('should dispatch GetCollectionProvider when providerId is present', () => { + setup(); + expect(store.dispatch).toHaveBeenCalledWith(new GetCollectionProvider(PROVIDER_ID)); + }); - expect(component.projectContributorsSaved()).toBe(false); + it('should dispatch GetCurrentCollectionSubmission when route has project id and collection exists', () => { + setup({ + routeParams: { providerId: PROVIDER_ID, id: MOCK_PROJECT.id }, + selectorOverrides: [ + { selector: CollectionsSelectors.getCollectionProvider, value: createMockCollectionProvider() }, + ], + }); + expect(store.dispatch).toHaveBeenCalledWith(new GetCurrentCollectionSubmission('col-1', MOCK_PROJECT.id)); + }); + + it('should dispatch SetSelectedProject when submission has project and none selected', () => { + const submission: CollectionProjectSubmission = { + project: MOCK_PROJECT, + submission: { + ...MOCK_COLLECTION_SUBMISSION_1, + reviewsState: CollectionSubmissionReviewState.Pending, + }, + }; + setup({ + selectorOverrides: [ + { selector: CollectionsSelectors.getCollectionProvider, value: createMockCollectionProvider() }, + { selector: AddToCollectionSelectors.getCurrentCollectionSubmission, value: submission }, + ], + }); + expect(store.dispatch).toHaveBeenCalledWith(new SetSelectedProject(MOCK_PROJECT)); + }); + + it('should apply branding when collection provider has brand', () => { + const brand = { + id: 'b1', + name: 'B', + heroLogoImageUrl: 'https://x/h.png', + heroBackgroundImageUrl: 'https://x/hb.png', + topNavLogoImageUrl: 'https://x/n.png', + primaryColor: '#111', + secondaryColor: '#222', + backgroundColor: '#333', + }; + setup({ + selectorOverrides: [ + { selector: CollectionsSelectors.getCollectionProvider, value: createMockCollectionProvider({ brand }) }, + ], + }); + expect(brandServiceMock.applyBranding).toHaveBeenCalledWith(brand); + expect(headerStyleServiceMock.applyHeaderStyles).toHaveBeenCalledWith('#222', '#333'); + }); + + it('should reset saved flags when project is selected', () => { + setup(); + component.projectMetadataSaved.set(true); + component.projectContributorsSaved.set(true); + component.allowNavigation.set(true); + component.handleProjectSelected(); expect(component.projectMetadataSaved()).toBe(false); + expect(component.projectContributorsSaved()).toBe(false); expect(component.allowNavigation()).toBe(false); }); - it('should handle step change', () => { - const newStep = AddToCollectionSteps.ProjectMetadata; - component.handleChangeStep(newStep); - - expect(component.stepperActiveValue()).toBe(newStep); + it('should update stepper value on step change', () => { + setup(); + component.handleChangeStep(AddToCollectionSteps.ProjectMetadata); + expect(component.stepperActiveValue()).toBe(AddToCollectionSteps.ProjectMetadata); }); - it('should handle project metadata saved', () => { + it('should mark project metadata saved', () => { + setup(); component.handleProjectMetadataSaved(); - expect(component.projectMetadataSaved()).toBe(true); }); - it('should handle contributors saved', () => { + it('should mark contributors saved and move to collection metadata step', () => { + setup(); component.handleContributorsSaved(); - - expect(component.stepperActiveValue()).toBe(AddToCollectionSteps.CollectionMetadata); expect(component.projectContributorsSaved()).toBe(true); + expect(component.stepperActiveValue()).toBe(AddToCollectionSteps.CollectionMetadata); }); - it('should handle collection metadata saved', () => { - const mockForm = new FormGroup({}); - component.handleCollectionMetadataSaved(mockForm); - - expect(component.collectionMetadataForm).toBe(mockForm); + it('should store collection metadata form and complete step', () => { + setup(); + const form = new FormGroup({}); + component.handleCollectionMetadataSaved(form); + expect(component.collectionMetadataForm).toBe(form); expect(component.collectionMetadataSaved()).toBe(true); expect(component.stepperActiveValue()).toBe(AddToCollectionSteps.Complete); }); - it('should handle cedar data saved', () => { - const mockCedarData: CedarRecordDataBinding = { - data: {} as CedarRecordDataBinding['data'], - id: 'template-123', - isPublished: false, - }; - component.handleCedarDataSaved(mockCedarData); + it('should return true from canDeactivate when navigation is allowed', () => { + setup(); + component.allowNavigation.set(true); + expect(component.canDeactivate()).toBe(true); + }); - expect(component.pendingCedarData()).toEqual(mockCedarData); - expect(component.collectionMetadataSaved()).toBe(true); - expect(component.stepperActiveValue()).toBe(AddToCollectionSteps.Complete); + it('should return true from canDeactivate when there are no unsaved changes', () => { + setup(); + expect(component.canDeactivate()).toBe(true); + }); + + it('should return false from canDeactivate when there are unsaved changes', () => { + setup({ + selectorOverrides: [{ selector: ProjectsSelectors.getSelectedProject, value: MOCK_PROJECT }], + }); + expect(component.canDeactivate()).toBe(false); + }); + + it('should warn on beforeunload when there are unsaved changes', () => { + setup({ + selectorOverrides: [{ selector: ProjectsSelectors.getSelectedProject, value: MOCK_PROJECT }], + }); + const event = { preventDefault: vi.fn() } as unknown as BeforeUnloadEvent; + const result = component.onBeforeUnload(event); + expect(event.preventDefault).toHaveBeenCalled(); + expect(result).toBe(false); }); - it('should have actions defined', () => { - expect(component.actions).toBeDefined(); - expect(component.actions.getCollectionProvider).toBeDefined(); - expect(component.actions.clearAddToCollectionState).toBeDefined(); + it('should not prevent beforeunload when navigation is allowed', () => { + setup({ + selectorOverrides: [{ selector: ProjectsSelectors.getSelectedProject, value: MOCK_PROJECT }], + }); + component.allowNavigation.set(true); + const event = { preventDefault: vi.fn() } as unknown as BeforeUnloadEvent; + const result = component.onBeforeUnload(event); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); }); - it('should handle loading state', () => { - expect(component.isProviderLoading()).toBe(false); + it('should open confirmation dialog when adding in create mode', () => { + setup({ + selectorOverrides: [ + { selector: CollectionsSelectors.getCollectionProvider, value: createMockCollectionProvider() }, + { selector: ProjectsSelectors.getSelectedProject, value: MOCK_PROJECT }, + ], + }); + component.handleCollectionMetadataSaved(new FormGroup({})); + component.handleAddToCollection(); + expect(customDialogMock.open).toHaveBeenCalledWith( + AddToCollectionConfirmationDialogComponent, + expect.objectContaining({ + header: 'collections.addToCollection.confirmationDialogHeader', + width: '500px', + data: expect.objectContaining({ + project: MOCK_PROJECT, + payload: expect.objectContaining({ + collectionId: 'col-1', + projectId: MOCK_PROJECT.id, + userId: MOCK_USER.id, + }), + }), + }) + ); }); - it('should have collection provider data', () => { - expect(component.collectionProvider()).toEqual(mockCollectionProvider); + it('should navigate after confirmation dialog closes with a truthy result', () => { + setup({ + selectorOverrides: [ + { selector: CollectionsSelectors.getCollectionProvider, value: createMockCollectionProvider() }, + { selector: ProjectsSelectors.getSelectedProject, value: MOCK_PROJECT }, + ], + }); + component.handleCollectionMetadataSaved(new FormGroup({})); + component.handleAddToCollection(); + dialogCloseSubject.next(true); + expect(routerMock.navigate).toHaveBeenCalledWith([MOCK_PROJECT.id, 'overview']); + }); + + it('should update submission in edit mode and navigate on success', () => { + setup({ + routeParams: { providerId: PROVIDER_ID, id: MOCK_PROJECT.id }, + selectorOverrides: [ + { selector: CollectionsSelectors.getCollectionProvider, value: createMockCollectionProvider() }, + { selector: ProjectsSelectors.getSelectedProject, value: MOCK_PROJECT }, + ], + }); + component.handleCollectionMetadataSaved(new FormGroup({})); + (store.dispatch as Mock).mockClear(); + component.handleAddToCollection(); + expect(loaderServiceMock.show).toHaveBeenCalled(); + expect(loaderServiceMock.hide).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith( + new UpdateCollectionSubmission({ + collectionId: 'col-1', + projectId: MOCK_PROJECT.id, + collectionMetadata: {}, + userId: MOCK_USER.id, + }) + ); + expect(toastServiceMock.showSuccess).toHaveBeenCalledWith( + 'collections.addToCollection.confirmationDialogToastMessage' + ); + expect(routerMock.navigate).toHaveBeenCalledWith([MOCK_PROJECT.id, 'overview']); + }); + + it('should not open remove dialog when project is missing', () => { + setup({ + routeParams: { providerId: PROVIDER_ID, id: MOCK_PROJECT.id }, + selectorOverrides: [ + { selector: CollectionsSelectors.getCollectionProvider, value: createMockCollectionProvider() }, + ], + }); + component.handleRemoveFromCollection(); + expect(customDialogMock.open).not.toHaveBeenCalled(); }); - it('should have selected project data', () => { - expect(component.selectedProject()).toEqual(MOCK_PROJECT); + it('should clear state on destroy in browser', () => { + setup(); + (store.dispatch as Mock).mockClear(); + fixture.destroy(); + expect(store.dispatch).toHaveBeenCalledWith(expect.any(ClearAddToCollectionState)); }); - it('should have current user data', () => { - expect(component.currentUser()).toEqual(MOCK_USER); + it('should not dispatch clear state on destroy when not in browser', () => { + setup({ platformId: 'server' }); + (store.dispatch as Mock).mockClear(); + fixture.destroy(); + expect(store.dispatch).not.toHaveBeenCalled(); }); }); diff --git a/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts b/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts index c90a8cee2..307bab0e8 100644 --- a/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts +++ b/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts @@ -23,18 +23,9 @@ import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { FormGroup } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; -import { ENVIRONMENT } from '@core/provider/environment.provider'; import { UserSelectors } from '@core/store/user'; -import { CedarMetadataRecordData, CedarRecordDataBinding } from '@osf/features/metadata/models'; -import { - CreateCedarMetadataRecord, - GetCedarMetadataRecords, - MetadataSelectors, - UpdateCedarMetadataRecord, -} from '@osf/features/metadata/store'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { CollectionSubmissionReviewState } from '@osf/shared/enums/collection-submission-review-state.enum'; -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { CanDeactivateComponent } from '@osf/shared/models/can-deactivate.interface'; import { BrandService } from '@osf/shared/services/brand.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; @@ -90,7 +81,6 @@ export class AddToCollectionComponent implements CanDeactivateComponent { private readonly headerStyleHelper = inject(HeaderStyleService); private readonly platformId = inject(PLATFORM_ID); private readonly isBrowser = isPlatformBrowser(this.platformId); - private readonly environment = inject(ENVIRONMENT); readonly selectedProjectId = toSignal( this.route.params.pipe(map((params) => params['id'])) ?? of(null) @@ -102,18 +92,15 @@ export class AddToCollectionComponent implements CanDeactivateComponent { isProviderLoading = select(CollectionsSelectors.getCollectionProviderLoading); collectionProvider = select(CollectionsSelectors.getCollectionProvider); - requiredMetadataTemplate = select(CollectionsSelectors.getRequiredMetadataTemplate); selectedProject = select(ProjectsSelectors.getSelectedProject); currentUser = select(UserSelectors.getCurrentUser); currentCollectionSubmission = select(AddToCollectionSelectors.getCurrentCollectionSubmission); - cedarRecords = select(MetadataSelectors.getCedarRecords); providerId = signal(''); allowNavigation = signal(false); projectMetadataSaved = signal(false); projectContributorsSaved = signal(false); collectionMetadataSaved = signal(false); - pendingCedarData = signal(null); stepperActiveValue = signal(AddToCollectionSteps.SelectProject); primaryCollectionId = computed(() => this.collectionProvider()?.primaryCollection?.id); @@ -123,13 +110,6 @@ export class AddToCollectionComponent implements CanDeactivateComponent { isCollectionMetadataDisabled = computed( () => !this.selectedProject() || !this.projectMetadataSaved() || !this.projectContributorsSaved() ); - isCedarMode = computed(() => this.environment.collectionSubmissionWithCedar && !!this.requiredMetadataTemplate()); - existingCedarRecord = computed(() => { - const records = this.cedarRecords(); - const templateId = this.requiredMetadataTemplate()?.id; - if (!records?.length || !templateId) return null; - return records.find((r) => r.relationships?.template?.data?.id === templateId) ?? null; - }); readonly actions = createDispatchMap({ getCollectionProvider: GetCollectionProvider, @@ -138,9 +118,6 @@ export class AddToCollectionComponent implements CanDeactivateComponent { deleteCollectionSubmission: RemoveCollectionSubmission, setSelectedProject: SetSelectedProject, getCurrentCollectionSubmission: GetCurrentCollectionSubmission, - getCedarRecords: GetCedarMetadataRecords, - createCedarRecord: CreateCedarMetadataRecord, - updateCedarRecord: UpdateCedarMetadataRecord, }); showRemoveButton = computed( @@ -197,29 +174,20 @@ export class AddToCollectionComponent implements CanDeactivateComponent { this.stepperActiveValue.set(AddToCollectionSteps.Complete); } - handleCedarDataSaved(data: CedarRecordDataBinding): void { - this.pendingCedarData.set(data); - this.collectionMetadataSaved.set(true); - this.stepperActiveValue.set(AddToCollectionSteps.Complete); - } - handleAddToCollection() { const payload = { collectionId: this.primaryCollectionId() || '', projectId: this.selectedProject()?.id || '', - collectionMetadata: this.isCedarMode() ? {} : this.collectionMetadataForm.value || {}, + collectionMetadata: this.collectionMetadataForm.value || {}, userId: this.currentUser()?.id || '', }; - const isEditMode = this.isEditMode(); - - if (isEditMode) { + if (this.isEditMode()) { this.loaderService.show(); this.actions .updateCollectionSubmission(payload) .pipe( - switchMap(() => this.saveCedarRecordIfNeeded()), finalize(() => this.loaderService.hide()), takeUntilDestroyed(this.destroyRef) ) @@ -242,7 +210,6 @@ export class AddToCollectionComponent implements CanDeactivateComponent { }) .onClose.pipe( filter((res) => !!res), - switchMap(() => this.saveCedarRecordIfNeeded()), takeUntilDestroyed(this.destroyRef) ) .subscribe({ @@ -278,35 +245,21 @@ export class AddToCollectionComponent implements CanDeactivateComponent { collectionId, comment: res?.comment || '', }; - + this.loaderService.show(); return this.actions.deleteCollectionSubmission(payload); }), + finalize(() => this.loaderService.hide()), takeUntilDestroyed(this.destroyRef) ) .subscribe({ next: () => { this.toastService.showSuccess('collections.removeDialog.success'); - this.loaderService.show(); this.allowNavigation.set(true); this.router.navigate([projectId, 'overview']); }, }); } - private saveCedarRecordIfNeeded(): Observable { - if (!this.isCedarMode()) return of(null); - - const cedarData = this.pendingCedarData(); - const projectId = this.selectedProject()?.id; - const templateId = this.requiredMetadataTemplate()?.id; - if (!cedarData || !projectId || !templateId) return of(null); - - const existingId = this.existingCedarRecord()?.id; - return existingId - ? this.actions.updateCedarRecord(cedarData, existingId, projectId, ResourceType.Project) - : this.actions.createCedarRecord(cedarData, projectId, ResourceType.Project); - } - private initializeProvider(): void { const id = this.route.snapshot.paramMap.get('providerId'); if (!id) { @@ -345,14 +298,6 @@ export class AddToCollectionComponent implements CanDeactivateComponent { this.actions.setSelectedProject(submission.project); } }); - - effect(() => { - const projectId = this.selectedProjectId(); - const isCedar = this.isCedarMode(); - if (isCedar && projectId) { - this.actions.getCedarRecords(projectId, ResourceType.Project); - } - }); } private setupCleanup() { diff --git a/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.html b/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.html index 0b0cd6498..f10094962 100644 --- a/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.html +++ b/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.html @@ -11,25 +11,14 @@

{{ 'collections.addToCollection.collectionMetadata' | translate }}

@if (!isDisabled() && stepperActiveValue() !== targetStepValue()) { @if (collectionMetadataSaved()) { - @if (isCedarMode()) { - @if (cedarTemplate()) { - - } - } @else { - @for (filterEntry of availableFilterEntries(); track filterEntry.key) { -
-

{{ filterEntry.labelKey | translate }}

+ @for (filterEntry of availableFilterEntries(); track filterEntry.key) { +
+

{{ filterEntry.labelKey | translate }}

-

- {{ collectionMetadataForm().get(filterEntry.key)?.value }} -

-
- } +

+ {{ collectionMetadataForm().get(filterEntry.key)?.value }} +

+
} } @@ -46,59 +35,33 @@

{{ 'collections.addToCollection.collectionMetadata' | translate }}

- @if (isCedarMode()) { - @if (cedarTemplate()) { -
- +
+ @for (filterEntry of availableFilterEntries(); track filterEntry.key) { +
+ +
- -
- - -
- } @else { -

{{ 'collections.addToCollection.cedarFormNotAvailable' | translate }}

} - } @else { - - @for (filterEntry of availableFilterEntries(); track filterEntry.key) { -
- - -
- } -
+ -
- - -
- } +
+ + +
diff --git a/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.spec.ts b/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.spec.ts index f6dc67b64..8f568269e 100644 --- a/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.spec.ts +++ b/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.spec.ts @@ -7,10 +7,8 @@ import { FormControl, FormGroup, Validators } from '@angular/forms'; import { AddToCollectionSteps } from '@osf/features/collections/enums'; import { AddToCollectionSelectors } from '@osf/features/collections/store/add-to-collection'; -import { CedarMetadataDataTemplateJsonApi, CedarMetadataRecordData } from '@osf/features/metadata/models'; import { CollectionsSelectors } from '@shared/stores/collections'; -import { MOCK_CEDAR_TEMPLATE } from '@testing/data/collections/cedar-metadata.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { provideMockStore } from '@testing/providers/store-provider.mock'; @@ -20,7 +18,7 @@ describe('CollectionMetadataStepComponent', () => { let component: CollectionMetadataStepComponent; let fixture: ComponentFixture; - function setup(isCedarMode = false, cedarTemplate: CedarMetadataDataTemplateJsonApi | null = null) { + function setup() { TestBed.configureTestingModule({ imports: [CollectionMetadataStepComponent, MockComponents(StepPanel, Step, StepItem)], providers: [ @@ -43,10 +41,6 @@ describe('CollectionMetadataStepComponent', () => { fixture.componentRef.setInput('targetStepValue', 1); fixture.componentRef.setInput('isDisabled', false); fixture.componentRef.setInput('primaryCollectionId', 'test-collection-id'); - fixture.componentRef.setInput('isCedarMode', isCedarMode); - if (cedarTemplate) { - fixture.componentRef.setInput('cedarTemplate', cedarTemplate); - } fixture.detectChanges(); } @@ -63,7 +57,6 @@ describe('CollectionMetadataStepComponent', () => { expect(component.stepperActiveValue()).toBe(0); expect(component.targetStepValue()).toBe(1); expect(component.isDisabled()).toBe(false); - expect(component.isCedarMode()).toBe(false); }); it('should handle save metadata in filter mode', () => { @@ -125,94 +118,4 @@ describe('CollectionMetadataStepComponent', () => { expect(component.targetStepValue()).toBe(3); expect(component.isDisabled()).toBe(true); }); - - describe('CEDAR mode', () => { - beforeEach(() => { - setup(true, MOCK_CEDAR_TEMPLATE); - }); - - it('should initialize in CEDAR mode', () => { - expect(component.isCedarMode()).toBe(true); - expect(component.cedarTemplate()).toEqual(MOCK_CEDAR_TEMPLATE); - }); - - it('should handle discard changes in CEDAR mode', () => { - component.cedarFormData.set({ field: 'value' }); - component.collectionMetadataSaved.set(true); - - component.handleDiscardChanges(); - - expect(component.collectionMetadataSaved()).toBe(false); - expect(component.cedarFormData()).toEqual({}); - }); - - it('should handle discard changes with existing record in CEDAR mode', () => { - const existingRecord: CedarMetadataRecordData = { - attributes: { - metadata: { field: 'original' } as unknown as CedarMetadataRecordData['attributes']['metadata'], - is_published: false, - }, - relationships: { - template: { data: { type: 'cedar-metadata-templates', id: 'template-1' } }, - target: { data: { type: 'nodes', id: 'node-1' } }, - }, - }; - fixture.componentRef.setInput('existingCedarRecord', existingRecord); - fixture.detectChanges(); - - component.collectionMetadataSaved.set(true); - component.handleDiscardChanges(); - - expect(component.collectionMetadataSaved()).toBe(false); - }); - - it('should populate cedarFormData from existingCedarRecord', () => { - const existingRecord: CedarMetadataRecordData = { - attributes: { - metadata: { field: 'existing' } as unknown as CedarMetadataRecordData['attributes']['metadata'], - is_published: true, - }, - relationships: { - template: { data: { type: 'cedar-metadata-templates', id: 'template-1' } }, - target: { data: { type: 'nodes', id: 'node-1' } }, - }, - }; - fixture.componentRef.setInput('existingCedarRecord', existingRecord); - fixture.detectChanges(); - - expect(component.cedarFormData()).toEqual({ field: 'existing' }); - }); - - it('should emit cedarDataSaved when handleSaveCedarMetadata is called without editor', () => { - const cedarDataSavedSpy = vi.spyOn(component.cedarDataSaved, 'emit'); - const stepChangeSpy = vi.spyOn(component.stepChange, 'emit'); - - component.handleSaveCedarMetadata(); - - expect(cedarDataSavedSpy).not.toHaveBeenCalled(); - expect(stepChangeSpy).not.toHaveBeenCalled(); - }); - - it('should handle onCedarChange event', () => { - const mockMetadata = { field: 'changed' }; - const mockEditor = { currentMetadata: mockMetadata } as unknown as EventTarget; - const mockEvent = new CustomEvent('change'); - Object.defineProperty(mockEvent, 'target', { value: mockEditor }); - - component.onCedarChange(mockEvent); - - expect(component.cedarFormData()).toEqual(mockMetadata); - }); - - it('should not call handleSaveCedarMetadata without template', () => { - fixture.componentRef.setInput('cedarTemplate', null); - fixture.detectChanges(); - - const cedarDataSavedSpy = vi.spyOn(component.cedarDataSaved, 'emit'); - - component.handleSaveCedarMetadata(); - - expect(cedarDataSavedSpy).not.toHaveBeenCalled(); - }); - }); }); diff --git a/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.ts b/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.ts index b4fe45f64..5c57c30d9 100644 --- a/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.ts +++ b/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.ts @@ -7,32 +7,13 @@ import { Select } from 'primeng/select'; import { Step, StepItem, StepPanel } from 'primeng/stepper'; import { Tooltip } from 'primeng/tooltip'; -import { - ChangeDetectionStrategy, - Component, - computed, - CUSTOM_ELEMENTS_SCHEMA, - effect, - ElementRef, - input, - output, - signal, - viewChild, - ViewEncapsulation, -} from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, effect, input, output, signal } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { collectionFilterTypes } from '@osf/features/collections/constants'; import { AddToCollectionSteps, CollectionFilterType } from '@osf/features/collections/enums'; import { CollectionFilterEntry } from '@osf/features/collections/models/collection-filter-entry.model'; import { AddToCollectionSelectors } from '@osf/features/collections/store/add-to-collection'; -import { CEDAR_CONFIG, CEDAR_VIEWER_CONFIG } from '@osf/features/metadata/constants'; -import { - CedarEditorElement, - CedarMetadataDataTemplateJsonApi, - CedarMetadataRecordData, - CedarRecordDataBinding, -} from '@osf/features/metadata/models'; import { CollectionSubmissionWithGuid } from '@osf/shared/models/collections/collections.model'; import { CollectionsSelectors, GetCollectionDetails } from '@osf/shared/stores/collections'; @@ -42,8 +23,6 @@ import { CollectionsSelectors, GetCollectionDetails } from '@osf/shared/stores/c templateUrl: './collection-metadata-step.component.html', styleUrl: './collection-metadata-step.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - schemas: [CUSTOM_ELEMENTS_SCHEMA], - encapsulation: ViewEncapsulation.None, }) export class CollectionMetadataStepComponent { private readonly filterTypes = collectionFilterTypes; @@ -66,25 +45,14 @@ export class CollectionMetadataStepComponent { targetStepValue = input.required(); isDisabled = input.required(); primaryCollectionId = input(); - isCedarMode = input(false); - cedarTemplate = input(null); - existingCedarRecord = input(null); stepChange = output(); metadataSaved = output(); - cedarDataSaved = output(); collectionMetadataForm = signal(new FormGroup({})); collectionMetadataSaved = signal(false); originalFormValues = signal>({}); formPopulatedFromSubmission = signal(false); - cedarFormData = signal>({}); - - cedarConfig = CEDAR_CONFIG; - cedarViewerConfig = CEDAR_VIEWER_CONFIG; - - cedarEditor = viewChild>('cedarEditor'); - cedarViewer = viewChild>('cedarViewer'); private readonly actions = createDispatchMap({ getCollectionDetails: GetCollectionDetails }); @@ -97,19 +65,6 @@ export class CollectionMetadataStepComponent { } handleDiscardChanges() { - if (this.isCedarMode()) { - const record = this.existingCedarRecord(); - this.cedarFormData.set( - record?.attributes?.metadata ? (record.attributes.metadata as Record) : {} - ); - const editor = this.cedarEditor()?.nativeElement; - if (editor) { - editor.instanceObject = this.cedarFormData(); - } - this.collectionMetadataSaved.set(false); - return; - } - const form = this.collectionMetadataForm(); const originalValues = this.originalFormValues(); @@ -130,39 +85,6 @@ export class CollectionMetadataStepComponent { this.stepChange.emit(AddToCollectionSteps.Complete); } - handleSaveCedarMetadata() { - const editor = this.cedarEditor()?.nativeElement; - const template = this.cedarTemplate(); - if (!editor || !template) return; - - const currentMetadata = editor.currentMetadata; - const isValid = !!editor.dataQualityReport?.isValid; - - if (currentMetadata) { - this.cedarFormData.set(currentMetadata as Record); - } - - const cedarData: CedarRecordDataBinding = { - data: currentMetadata as CedarRecordDataBinding['data'], - id: template.id, - isPublished: isValid, - }; - - this.collectionMetadataSaved.set(true); - this.cedarDataSaved.emit(cedarData); - this.stepChange.emit(AddToCollectionSteps.Complete); - } - - onCedarChange(event: Event): void { - const customEvent = event as CustomEvent; - if (customEvent?.target) { - const editor = customEvent.target as CedarEditorElement; - if (editor && typeof editor.currentMetadata !== 'undefined') { - this.cedarFormData.set(editor.currentMetadata as Record); - } - } - } - private buildCollectionMetadataForm() { const filterEntries = this.availableFilterEntries(); const formControls: Record = {}; @@ -193,21 +115,9 @@ export class CollectionMetadataStepComponent { } }); - effect(() => { - const record = this.existingCedarRecord(); - if (record?.attributes?.metadata) { - const metadata = record.attributes.metadata as Record; - this.cedarFormData.set(metadata); - const editor = this.cedarEditor()?.nativeElement; - if (editor) editor.instanceObject = metadata; - const viewer = this.cedarViewer()?.nativeElement; - if (viewer) viewer.instanceObject = metadata; - } - }); - effect(() => { const filterEntries = this.availableFilterEntries(); - if (filterEntries.length && !this.isCedarMode()) { + if (filterEntries.length) { this.buildCollectionMetadataForm(); } }); @@ -223,8 +133,7 @@ export class CollectionMetadataStepComponent { form.controls && Object.keys(form.controls).length > 0 && filterEntries.length > 0 && - !alreadyPopulated && - !this.isCedarMode() + !alreadyPopulated ) { this.populateFormFromSubmission(submission.submission); this.formPopulatedFromSubmission.set(true); @@ -233,10 +142,8 @@ export class CollectionMetadataStepComponent { effect(() => { if (!this.collectionMetadataSaved() && this.stepperActiveValue() !== AddToCollectionSteps.CollectionMetadata) { - if (!this.isCedarMode()) { - this.collectionMetadataForm().reset(); - this.formPopulatedFromSubmission.set(false); - } + this.collectionMetadataForm().reset(); + this.formPopulatedFromSubmission.set(false); } }); } diff --git a/src/app/features/collections/components/collections-discover/collections-discover.component.spec.ts b/src/app/features/collections/components/collections-discover/collections-discover.component.spec.ts index 7be0470cd..86f448e72 100644 --- a/src/app/features/collections/components/collections-discover/collections-discover.component.spec.ts +++ b/src/app/features/collections/components/collections-discover/collections-discover.component.spec.ts @@ -4,258 +4,345 @@ import { MockComponents, MockProvider } from 'ng-mocks'; import { Mock } from 'vitest'; +import { PLATFORM_ID } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, provideRouter, Router } from '@angular/router'; import { ENVIRONMENT } from '@core/provider/environment.provider'; -import { GlobalSearchComponent } from '@osf/shared/components/global-search/global-search.component'; -import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; -import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component'; -import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; -import { ToastService } from '@osf/shared/services/toast.service'; -import { CollectionsSelectors } from '@shared/stores/collections'; -import { SetDefaultFilterValue, SetExtraFilters } from '@shared/stores/global-search'; - -import { MOCK_PROVIDER } from '@testing/mocks/provider.mock'; +import { GlobalSearchComponent } from '@shared/components/global-search/global-search.component'; +import { LoadingSpinnerComponent } from '@shared/components/loading-spinner/loading-spinner.component'; +import { SearchInputComponent } from '@shared/components/search-input/search-input.component'; +import { CollectionDetails, CollectionProvider } from '@shared/models/collections/collections.model'; +import { EnvironmentModel } from '@shared/models/environment.model'; +import { FilterOperatorOption } from '@shared/models/search/discoverable-filter.model'; +import { BrandService } from '@shared/services/brand.service'; +import { CustomDialogService } from '@shared/services/custom-dialog.service'; +import { HeaderStyleService } from '@shared/services/header-style.service'; +import { + CollectionsSelectors, + GetCollectionDetails, + GetCollectionProvider, + SearchCollectionSubmissions, + SetPageNumber, + SetSearchValue, +} from '@shared/stores/collections'; +import { ResetSearchState, SetDefaultFilterValue, SetExtraFilters } from '@shared/stores/global-search'; + +import { CEDAR_METADATA_DATA_TEMPLATE_JSON_API_MOCK } from '@testing/mocks/cedar-metadata-data-template-json-api.mock'; +import { MOCK_COLLECTIONS_EMPTY_FILTERS } from '@testing/mocks/collections-filters.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; -import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; +import { BrandServiceMock, BrandServiceMockType } from '@testing/providers/brand-service.mock'; +import { CustomDialogServiceMock, CustomDialogServiceMockType } from '@testing/providers/custom-dialog-provider.mock'; +import { EnvironmentTokenMock } from '@testing/providers/environment.token.mock'; +import { HeaderStyleServiceMock, HeaderStyleServiceMockType } from '@testing/providers/header-style-service.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMock } from '@testing/providers/toast-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; +import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; import { CollectionsQuerySyncService } from '../../services'; +import { CollectionsHelpDialogComponent } from '../collections-help-dialog/collections-help-dialog.component'; import { CollectionsMainContentComponent } from '../collections-main-content/collections-main-content.component'; import { CollectionsDiscoverComponent } from './collections-discover.component'; -const MOCK_COLLECTION_PROVIDER = { - ...MOCK_PROVIDER, - primaryCollection: { id: 'collection-1', type: 'collections' }, - requiredMetadataTemplate: null, -}; - -const MOCK_COLLECTION_PROVIDER_WITH_TEMPLATE = { - ...MOCK_COLLECTION_PROVIDER, - requiredMetadataTemplate: { - id: 'template-1', - type: 'cedar-metadata-templates' as const, - attributes: { - schema_name: 'Test', - cedar_id: 'cedar-1', - template: { - '@id': 'https://repo.metadatacenter.org/templates/test', - '@type': 'https://schema.metadatacenter.org/core/Template', - type: 'object', - title: 'Test', - description: '', - $schema: 'http://json-schema.org/draft-04/schema', - '@context': {} as never, - required: [], - properties: {}, - _ui: { - order: ['field1'], - propertyLabels: { field1: 'Field One' }, - propertyDescriptions: {}, - }, - }, - }, +const PROVIDER_ID = 'provider-1'; + +const mockCollectionDetails: CollectionDetails = { + id: 'col-1', + type: 'collections', + title: 'Collection', + dateCreated: '2024-01-01T00:00:00Z', + dateModified: '2024-01-02T00:00:00Z', + bookmarks: false, + isPromoted: false, + isPublic: true, + filters: { + collectedType: [], + disease: [], + dataType: [], + gradeLevels: [], + issue: [], + programArea: [], + schoolType: [], + status: [], + studyDesign: [], + volume: [], }, }; -interface SetupOptions { - collectionSubmissionWithCedar?: boolean; - provider?: typeof MOCK_COLLECTION_PROVIDER | typeof MOCK_COLLECTION_PROVIDER_WITH_TEMPLATE; +function createMockCollectionProvider(overrides: Partial = {}): CollectionProvider { + return { + id: PROVIDER_ID, + type: 'collection-providers', + name: 'Provider', + description: '', + domain: 'osf.io', + advisoryBoard: '', + allowCommenting: false, + allowSubmissions: true, + domainRedirectEnabled: false, + emailSupport: null, + example: null, + facebookAppId: null, + footerLinks: '', + permissions: [], + reviewsWorkflow: '', + sharePublishType: '', + shareSource: '', + iri: 'https://api.test.osf.io/v2/collections/col-1/', + assets: {}, + primaryCollection: { id: 'col-1', type: 'collections' }, + brand: null, + ...overrides, + } as CollectionProvider; } -function setup(options: SetupOptions = {}) { - const { collectionSubmissionWithCedar = false, provider = MOCK_COLLECTION_PROVIDER } = options; - - const toastServiceMock = ToastServiceMock.simple(); - const mockCustomDialogService = CustomDialogServiceMockBuilder.create().build(); - const mockRoute = ActivatedRouteMockBuilder.create().withParams({ providerId: 'provider-1' }).build(); - - TestBed.configureTestingModule({ - imports: [ - CollectionsDiscoverComponent, - ...MockComponents( - SearchInputComponent, - CollectionsMainContentComponent, - GlobalSearchComponent, - LoadingSpinnerComponent - ), - ], - providers: [ - provideOSFCore(), - { provide: ENVIRONMENT, useValue: { apiDomainUrl: 'http://localhost:8000', collectionSubmissionWithCedar } }, - MockProvider(ToastService, toastServiceMock), - MockProvider(CustomDialogService, mockCustomDialogService), - MockProvider(ActivatedRoute, mockRoute), - provideMockStore({ - signals: [ - { selector: CollectionsSelectors.getCollectionProvider, value: provider }, - { selector: CollectionsSelectors.getCollectionDetails, value: null }, - { selector: CollectionsSelectors.getAllSelectedFilters, value: {} }, - { selector: CollectionsSelectors.getSortBy, value: 'date' }, - { selector: CollectionsSelectors.getSearchText, value: '' }, - { selector: CollectionsSelectors.getPageNumber, value: '1' }, - { selector: CollectionsSelectors.getCollectionProviderLoading, value: false }, - ], - }), - ], - }).overrideComponent(CollectionsDiscoverComponent, { - set: { - providers: [MockProvider(CollectionsQuerySyncService)], - }, - }); - - const fixture = TestBed.createComponent(CollectionsDiscoverComponent); - const component = fixture.componentInstance; - const store = TestBed.inject(Store); - fixture.detectChanges(); - - return { fixture, component, store }; -} +const defaultSignals: SignalOverride[] = [ + { selector: CollectionsSelectors.getCollectionProviderLoading, value: false }, + { selector: CollectionsSelectors.getCollectionProvider, value: null }, + { selector: CollectionsSelectors.getCollectionDetails, value: null }, + { selector: CollectionsSelectors.getAllSelectedFilters, value: { ...MOCK_COLLECTIONS_EMPTY_FILTERS } }, + { selector: CollectionsSelectors.getSortBy, value: '' }, + { selector: CollectionsSelectors.getSearchText, value: '' }, + { selector: CollectionsSelectors.getPageNumber, value: '1' }, +]; describe('CollectionsDiscoverComponent', () => { - describe('legacy mode (collectionSubmissionWithCedar = false)', () => { - let component: CollectionsDiscoverComponent; - let fixture: ComponentFixture; - - beforeEach(() => { - ({ fixture, component } = setup()); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should set useShareTroveSearch to false', () => { - expect(component.useShareTroveSearch).toBe(false); - }); - - it('should initialize with default values', () => { - expect(component.providerId()).toBe('provider-1'); - expect(component.searchControl.value).toBe(''); + let component: CollectionsDiscoverComponent; + let fixture: ComponentFixture; + let store: Store; + let routerMock: RouterMockType; + let customDialogMock: CustomDialogServiceMockType; + let querySyncMock: Partial; + let brandServiceMock: BrandServiceMockType; + let headerStyleServiceMock: HeaderStyleServiceMockType; + + function setup( + options: { + routeParams?: Record; + hasParent?: boolean; + selectorOverrides?: SignalOverride[]; + useCedarEnvironment?: boolean; + platformId?: string; + } = {} + ) { + const routeBuilder = ActivatedRouteMockBuilder.create().withParams( + options.routeParams ?? { providerId: PROVIDER_ID } + ); + if (options.hasParent === false) { + routeBuilder.withNoParent(); + } + const mockRoute = routeBuilder.build(); + routerMock = RouterMockBuilder.create().withUrl('/collections/discover').build(); + customDialogMock = CustomDialogServiceMock.simple(); + querySyncMock = { + initializeFromUrl: vi.fn(), + syncStoreToUrl: vi.fn(), + }; + brandServiceMock = BrandServiceMock.simple(); + headerStyleServiceMock = HeaderStyleServiceMock.simple(); + + const envValue = { + ...EnvironmentTokenMock.useValue, + collectionSubmissionWithCedar: options.useCedarEnvironment ?? false, + } as unknown as EnvironmentModel; + + const signals = mergeSignalOverrides(defaultSignals, options.selectorOverrides); + + TestBed.configureTestingModule({ + imports: [ + CollectionsDiscoverComponent, + ...MockComponents( + SearchInputComponent, + CollectionsMainContentComponent, + GlobalSearchComponent, + LoadingSpinnerComponent + ), + ], + providers: [ + provideOSFCore(), + provideRouter([]), + MockProvider(ActivatedRoute, mockRoute), + MockProvider(Router, routerMock), + MockProvider(CustomDialogService, customDialogMock), + MockProvider(BrandService, brandServiceMock), + MockProvider(HeaderStyleService, headerStyleServiceMock), + MockProvider(PLATFORM_ID, options.platformId ?? 'browser'), + MockProvider(ENVIRONMENT, envValue), + provideMockStore({ signals }), + ], + }).overrideComponent(CollectionsDiscoverComponent, { + set: { + providers: [MockProvider(CollectionsQuerySyncService, querySyncMock)], + }, }); - it('should have collection provider data', () => { - expect(component.collectionProvider()).toEqual(MOCK_COLLECTION_PROVIDER); - }); + store = TestBed.inject(Store); + fixture = TestBed.createComponent(CollectionsDiscoverComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + } - it('should have collection details as null', () => { - expect(component.collectionDetails()).toBeNull(); - }); + it('should create', () => { + setup(); + expect(component).toBeTruthy(); + }); - it('should have selected filters', () => { - expect(component.selectedFilters()).toEqual({}); - }); + it('should initialize searchControl with empty string', () => { + setup(); + expect(component.searchControl.value).toBe(''); + }); - it('should have sort by value', () => { - expect(component.sortBy()).toBe('date'); - }); + it('should navigate to not-found when providerId param is missing', () => { + setup({ routeParams: {} }); + expect(routerMock.navigate).toHaveBeenCalledWith(['/not-found']); + }); - it('should have search text', () => { - expect(component.searchText()).toBe(''); - }); + it('should dispatch GetCollectionProvider when providerId is present', () => { + setup({ routeParams: { providerId: 'my-provider' } }); + expect(store.dispatch).toHaveBeenCalledWith(new GetCollectionProvider('my-provider')); + }); - it('should have page number', () => { - expect(component.pageNumber()).toBe('1'); + it('should open help dialog with expected header', () => { + setup(); + (store.dispatch as Mock).mockClear(); + component.openHelpDialog(); + expect(customDialogMock.open).toHaveBeenCalledWith(CollectionsHelpDialogComponent, { + header: 'collections.helpDialog.header', }); + }); - it('should have loading state', () => { - expect(component.isProviderLoading()).toBe(false); - }); + it('should dispatch search and page when search is triggered in legacy mode', () => { + setup(); + (store.dispatch as Mock).mockClear(); + component.onSearchTriggered('query'); + expect(store.dispatch).toHaveBeenCalledWith(new SetSearchValue('query')); + expect(store.dispatch).toHaveBeenCalledWith(new SetPageNumber('1')); + }); - it('should compute primary collection id', () => { - expect(component.primaryCollectionId()).toBe('collection-1'); - }); + it('should not dispatch search actions when search is triggered in cedar mode', () => { + setup({ useCedarEnvironment: true }); + (store.dispatch as Mock).mockClear(); + component.onSearchTriggered('query'); + expect(store.dispatch).not.toHaveBeenCalledWith(new SetSearchValue('query')); + expect(store.dispatch).not.toHaveBeenCalledWith(new SetPageNumber('1')); + }); - it('should handle search control value changes', () => { - component.searchControl.setValue('new search value'); - expect(component.searchControl.value).toBe('new search value'); + it('should call query sync initialize and sync when legacy mode store fields are ready', () => { + setup({ + selectorOverrides: [ + { selector: CollectionsSelectors.getCollectionProvider, value: createMockCollectionProvider() }, + { selector: CollectionsSelectors.getCollectionDetails, value: mockCollectionDetails }, + ], }); + expect(querySyncMock.initializeFromUrl).toHaveBeenCalled(); + expect(querySyncMock.syncStoreToUrl).toHaveBeenCalledWith('', '', MOCK_COLLECTIONS_EMPTY_FILTERS, '1'); + }); - it('should not initialize default search filters', () => { - expect(component.defaultSearchFiltersInitialized()).toBe(false); + it('should dispatch search collection submissions when legacy prerequisites are met', () => { + setup({ + selectorOverrides: [ + { selector: CollectionsSelectors.getCollectionProvider, value: createMockCollectionProvider() }, + { selector: CollectionsSelectors.getCollectionDetails, value: mockCollectionDetails }, + ], }); + expect(store.dispatch).toHaveBeenCalledWith(new SearchCollectionSubmissions(PROVIDER_ID, '', {}, '1', '')); + }); - it('should render CollectionsMainContentComponent', () => { - const el = fixture.nativeElement as HTMLElement; - expect(el.querySelector('osf-collections-main-content')).toBeTruthy(); - expect(el.querySelector('osf-global-search')).toBeNull(); + it('should apply branding when collection provider exposes brand', () => { + const brand = { + id: 'b1', + name: 'B', + heroLogoImageUrl: 'https://x/h.png', + heroBackgroundImageUrl: 'https://x/hb.png', + topNavLogoImageUrl: 'https://x/n.png', + primaryColor: '#111111', + secondaryColor: '#222222', + backgroundColor: '#333333', + }; + setup({ + selectorOverrides: [ + { + selector: CollectionsSelectors.getCollectionProvider, + value: createMockCollectionProvider({ brand }), + }, + ], }); + expect(brandServiceMock.applyBranding).toHaveBeenCalledWith(brand); + expect(headerStyleServiceMock.applyHeaderStyles).toHaveBeenCalledWith('#222222', '#333333'); + }); - it('should dispatch setSearchValue and setPageNumber on search triggered', () => { - const { component: localComponent, store: localStore } = setup(); - (localStore.dispatch as Mock).mockClear(); - - localComponent.onSearchTriggered('my query'); - - const calls = (localStore.dispatch as Mock).mock.calls.flat(); - expect(calls.some((c: unknown) => c instanceof SetDefaultFilterValue)).toBe(false); + it('should dispatch GetCollectionDetails when primary collection id is available', () => { + setup({ + selectorOverrides: [ + { selector: CollectionsSelectors.getCollectionProvider, value: createMockCollectionProvider() }, + ], }); + expect(store.dispatch).toHaveBeenCalledWith(new GetCollectionDetails('col-1')); }); - describe('shtrove mode (collectionSubmissionWithCedar = true)', () => { - it('should set useShareTroveSearch to true', () => { - const { component } = setup({ collectionSubmissionWithCedar: true }); - expect(component.useShareTroveSearch).toBe(true); + it('should dispatch cedar default filters and extra filters when provider and template load', () => { + const provider = createMockCollectionProvider({ + requiredMetadataTemplate: CEDAR_METADATA_DATA_TEMPLATE_JSON_API_MOCK, }); - - it('should initialize default search filters', () => { - const { component } = setup({ collectionSubmissionWithCedar: true }); - expect(component.defaultSearchFiltersInitialized()).toBe(true); + setup({ + useCedarEnvironment: true, + selectorOverrides: [{ selector: CollectionsSelectors.getCollectionProvider, value: provider }], }); + expect(store.dispatch).toHaveBeenCalledWith( + new SetDefaultFilterValue('isContainedBy', 'https://api.test.osf.io/v2/collections/col-1/') + ); + expect(store.dispatch).toHaveBeenCalledWith( + new SetExtraFilters([ + { + key: 'Project Name', + label: 'Project Name', + operator: FilterOperatorOption.AnyOf, + }, + ]) + ); + }); - it('should dispatch SetDefaultFilterValue with collection IRI', () => { - const { store } = setup({ collectionSubmissionWithCedar: true }); - const dispatched = (store.dispatch as Mock).mock.calls.flat(); - const setDefaultFilter = dispatched.find( - (c: unknown) => c instanceof SetDefaultFilterValue - ) as SetDefaultFilterValue; - - expect(setDefaultFilter).toBeDefined(); - expect(setDefaultFilter.filterKey).toBe('isContainedBy'); - expect(setDefaultFilter.value).toBe('http://localhost:8000/v2/collections/collection-1/'); - }); + it('should dispatch ResetSearchState on destroy in cedar mode', () => { + setup({ useCedarEnvironment: true }); + (store.dispatch as Mock).mockClear(); + fixture.destroy(); + expect(store.dispatch).toHaveBeenCalledWith(expect.any(ResetSearchState)); + }); - it('should not dispatch SetExtraFilters when provider has no requiredMetadataTemplate', () => { - const { store } = setup({ collectionSubmissionWithCedar: true }); - const dispatched = (store.dispatch as Mock).mock.calls.flat(); + it('should reset branding and header on destroy in browser', () => { + setup(); + fixture.destroy(); + expect(headerStyleServiceMock.resetToDefaults).toHaveBeenCalled(); + expect(brandServiceMock.resetBranding).toHaveBeenCalled(); + }); - expect(dispatched.some((c: unknown) => c instanceof SetExtraFilters)).toBe(false); - }); + it('should not dispatch clear actions or reset services on destroy when not in browser', () => { + setup({ platformId: 'server' }); + (store.dispatch as Mock).mockClear(); + brandServiceMock.resetBranding.mockClear(); + headerStyleServiceMock.resetToDefaults.mockClear(); + fixture.destroy(); + expect(store.dispatch).not.toHaveBeenCalled(); + expect(brandServiceMock.resetBranding).not.toHaveBeenCalled(); + expect(headerStyleServiceMock.resetToDefaults).not.toHaveBeenCalled(); + }); - it('should dispatch SetExtraFilters when provider has a requiredMetadataTemplate', () => { - const { store } = setup({ - collectionSubmissionWithCedar: true, - provider: MOCK_COLLECTION_PROVIDER_WITH_TEMPLATE, + it('should debounce search control changes and dispatch trimmed search value', () => { + vi.useFakeTimers(); + try { + setup({ + selectorOverrides: [ + { selector: CollectionsSelectors.getCollectionProvider, value: createMockCollectionProvider() }, + { selector: CollectionsSelectors.getCollectionDetails, value: mockCollectionDetails }, + ], }); - - const dispatched = (store.dispatch as Mock).mock.calls.flat(); - const setExtraFilters = dispatched.find((c: unknown) => c instanceof SetExtraFilters) as SetExtraFilters; - - expect(setExtraFilters).toBeDefined(); - expect(setExtraFilters.filters).toHaveLength(1); - expect(setExtraFilters.filters[0].key).toBe('field1'); - expect(setExtraFilters.filters[0].label).toBe('Field One'); - }); - - it('should render GlobalSearchComponent when filters are initialized', () => { - const { fixture } = setup({ collectionSubmissionWithCedar: true }); - const el = fixture.nativeElement as HTMLElement; - - expect(el.querySelector('osf-global-search')).toBeTruthy(); - expect(el.querySelector('osf-collections-main-content')).toBeNull(); - }); - - it('should not dispatch any action on onSearchTriggered in shtrove mode', () => { - const { component, store } = setup({ collectionSubmissionWithCedar: true }); (store.dispatch as Mock).mockClear(); - - component.onSearchTriggered('query'); - - expect(store.dispatch).not.toHaveBeenCalled(); - }); + component.searchControl.setValue(' trimmed '); + vi.advanceTimersByTime(300); + expect(store.dispatch).toHaveBeenCalledWith(new SetSearchValue('trimmed')); + } finally { + vi.useRealTimers(); + } }); }); diff --git a/src/app/features/collections/components/collections-discover/collections-discover.component.ts b/src/app/features/collections/components/collections-discover/collections-discover.component.ts index af6994b7e..0455286aa 100644 --- a/src/app/features/collections/components/collections-discover/collections-discover.component.ts +++ b/src/app/features/collections/components/collections-discover/collections-discover.component.ts @@ -164,12 +164,10 @@ export class CollectionsDiscoverComponent { private setupShareTroveSearchEffect(): void { effect(() => { const provider = this.collectionProvider(); - const collectionId = this.primaryCollectionId(); - if (!provider || !collectionId || this.defaultSearchFiltersInitialized()) return; + if (!provider || !provider.iri || this.defaultSearchFiltersInitialized()) return; - const collectionIri = `${this.environment.apiDomainUrl}/v2/collections/${collectionId}/`; - this.actions.setDefaultFilterValue('isContainedBy', collectionIri); + this.actions.setDefaultFilterValue('isContainedBy', provider.iri); if (provider.requiredMetadataTemplate?.attributes?.template) { const extraFilters = CedarTemplateFilterMapper.fromTemplate( 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 04a1848c0..718041d1e 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 @@ -56,8 +56,8 @@ export class AddToCollectionState { getCurrentCollectionSubmission(ctx: StateContext, action: GetCurrentCollectionSubmission) { const state = ctx.getState(); ctx.patchState({ - collectionLicenses: { - ...state.collectionLicenses, + currentProjectSubmission: { + ...state.currentProjectSubmission, isLoading: true, }, }); diff --git a/src/app/features/metadata/components/cedar-template-form/cedar-template-form.component.ts b/src/app/features/metadata/components/cedar-template-form/cedar-template-form.component.ts index 032e378f3..a128bab01 100644 --- a/src/app/features/metadata/components/cedar-template-form/cedar-template-form.component.ts +++ b/src/app/features/metadata/components/cedar-template-form/cedar-template-form.component.ts @@ -176,7 +176,10 @@ export class CedarTemplateFormComponent { onSubmit() { const editor = this.cedarEditor()?.nativeElement; if (editor && typeof editor.currentMetadata !== 'undefined') { - const finalData = { data: editor.currentMetadata, id: this.template().id, isPublished: this.isValid }; + const cleanedData = CedarMetadataHelper.cleanMetadataForSubmission( + editor.currentMetadata as Record + ); + const finalData = { data: cleanedData, id: this.template().id, isPublished: this.isValid }; this.formData.set(finalData); this.emitData.emit(finalData as CedarRecordDataBinding); } diff --git a/src/app/features/metadata/helpers/cedar-metadata.helper.spec.ts b/src/app/features/metadata/helpers/cedar-metadata.helper.spec.ts new file mode 100644 index 000000000..d5739c98f --- /dev/null +++ b/src/app/features/metadata/helpers/cedar-metadata.helper.spec.ts @@ -0,0 +1,171 @@ +import { CedarTemplate } from '../models'; + +import { CedarMetadataHelper } from './cedar-metadata.helper'; + +const MOCK_TEMPLATE: CedarTemplate = { + '@id': 'https://repo.metadatacenter.org/templates/test-id', + '@type': 'https://schema.metadatacenter.org/core/Template', + type: 'object', + title: 'Test Template', + description: 'Test', + $schema: 'http://json-schema.org/draft-04/schema#', + '@context': { + pav: 'http://purl.org/pav/', + xsd: 'http://www.w3.org/2001/XMLSchema#', + bibo: 'http://purl.org/ontology/bibo/', + oslc: 'http://open-services.net/ns/core#', + schema: 'http://schema.org/', + 'schema:name': { '@type': 'xsd:string' }, + 'pav:createdBy': { '@type': '@id' }, + 'pav:createdOn': { '@type': 'xsd:dateTime' }, + 'oslc:modifiedBy': { '@type': '@id' }, + 'pav:lastUpdatedOn': { '@type': 'xsd:dateTime' }, + 'schema:description': { '@type': 'xsd:string' }, + }, + required: [], + properties: {}, + _ui: { order: [], propertyLabels: {}, propertyDescriptions: {} }, +}; + +describe('CedarMetadataHelper', () => { + describe('ensureProperStructure', () => { + it('should return an empty array for non-array input', () => { + expect(CedarMetadataHelper.ensureProperStructure(null)).toEqual([]); + expect(CedarMetadataHelper.ensureProperStructure('string')).toEqual([]); + expect(CedarMetadataHelper.ensureProperStructure({})).toEqual([]); + }); + + it('should normalize array items to have @id, @type, rdfs:label', () => { + const input = [{ '@id': 'id1', '@type': 'type1', 'rdfs:label': 'label1' }]; + expect(CedarMetadataHelper.ensureProperStructure(input)).toEqual([ + { '@id': 'id1', '@type': 'type1', 'rdfs:label': 'label1' }, + ]); + }); + + it('should fill missing properties with defaults', () => { + const input = [{}]; + expect(CedarMetadataHelper.ensureProperStructure(input)).toEqual([ + { '@id': '', '@type': '', 'rdfs:label': null }, + ]); + }); + }); + + describe('buildCedarSystemMetadata', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-15T10:00:00.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should set @id to empty string', () => { + const result = CedarMetadataHelper.buildCedarSystemMetadata(MOCK_TEMPLATE); + expect(result['@id']).toBe(''); + }); + + it('should set schema:isBasedOn to the template @id', () => { + const result = CedarMetadataHelper.buildCedarSystemMetadata(MOCK_TEMPLATE); + expect(result['schema:isBasedOn']).toBe('https://repo.metadatacenter.org/templates/test-id'); + }); + + it('should set schema:name and schema:description to empty strings', () => { + const result = CedarMetadataHelper.buildCedarSystemMetadata(MOCK_TEMPLATE); + expect(result['schema:name']).toBe(''); + expect(result['schema:description']).toBe(''); + }); + + it('should set pav:createdBy and oslc:modifiedBy to empty strings', () => { + const result = CedarMetadataHelper.buildCedarSystemMetadata(MOCK_TEMPLATE); + expect(result['pav:createdBy']).toBe(''); + expect(result['oslc:modifiedBy']).toBe(''); + }); + + it('should set pav:createdOn and pav:lastUpdatedOn to the current timestamp', () => { + const result = CedarMetadataHelper.buildCedarSystemMetadata(MOCK_TEMPLATE); + expect(result['pav:createdOn']).toBe('2025-01-15T10:00:00.000Z'); + expect(result['pav:lastUpdatedOn']).toBe('2025-01-15T10:00:00.000Z'); + }); + + it('should copy @context from the template', () => { + const result = CedarMetadataHelper.buildCedarSystemMetadata(MOCK_TEMPLATE); + expect(result['@context']).toEqual(MOCK_TEMPLATE['@context']); + }); + + it('should use empty object for @context when template has none', () => { + const templateWithoutContext = { ...MOCK_TEMPLATE, '@context': undefined } as unknown as CedarTemplate; + const result = CedarMetadataHelper.buildCedarSystemMetadata(templateWithoutContext); + expect(result['@context']).toEqual({}); + }); + + it('should use empty string for schema:isBasedOn when template @id is missing', () => { + const templateWithoutId = { ...MOCK_TEMPLATE, '@id': undefined } as unknown as CedarTemplate; + const result = CedarMetadataHelper.buildCedarSystemMetadata(templateWithoutId); + expect(result['schema:isBasedOn']).toBe(''); + }); + }); + + describe('buildEmptyMetadata', () => { + it('should return an object with @context and LDbase-specific empty arrays', () => { + const result = CedarMetadataHelper.buildEmptyMetadata(); + expect(result['@context']).toEqual({}); + expect(result['Constructs']).toEqual([]); + expect(result['Assessments']).toEqual([]); + }); + }); + + describe('buildStructuredMetadata', () => { + it('should return metadata as-is for keys not in the fix list', () => { + const metadata = { customField: 'value' }; + expect(CedarMetadataHelper.buildStructuredMetadata(metadata)).toEqual({ customField: 'value' }); + }); + + it('should normalize array fields in the fix list', () => { + const metadata = { Constructs: [{ '@id': 'id1' }] }; + const result = CedarMetadataHelper.buildStructuredMetadata(metadata); + expect(result['Constructs']).toEqual([{ '@id': 'id1', '@type': '', 'rdfs:label': null }]); + }); + }); + + describe('cleanMetadataForSubmission', () => { + it('should pass through non-UUID top-level keys unchanged', () => { + const metadata = { '@id': '', 'schema:name': '', 'School Type': { '@value': 'High School' } }; + expect(CedarMetadataHelper.cleanMetadataForSubmission(metadata)).toEqual(metadata); + }); + + it('should remove UUID-format top-level keys', () => { + const metadata = { + '@id': '', + '052a3bf4-2003-42e4-bb38-a63e5e0fc0d3': { '@id': 'https://example.com' }, + 'School Type': { '@value': 'High School' }, + }; + const result = CedarMetadataHelper.cleanMetadataForSubmission(metadata); + expect(result['052a3bf4-2003-42e4-bb38-a63e5e0fc0d3']).toBeUndefined(); + expect(result['@id']).toBe(''); + expect(result['School Type']).toEqual({ '@value': 'High School' }); + }); + + it('should remove UUID-format keys from @context', () => { + const metadata = { + '@context': { + pav: 'http://purl.org/pav/', + 'schema:name': { '@type': 'xsd:string' }, + '052a3bf4-2003-42e4-bb38-a63e5e0fc0d3': 'https://repo.metadatacenter.org/template-fields/3de6ff2c', + 'School Type': 'https://schema.metadatacenter.org/properties/abc', + }, + '@id': '', + }; + const result = CedarMetadataHelper.cleanMetadataForSubmission(metadata); + const ctx = result['@context'] as Record; + expect(ctx['052a3bf4-2003-42e4-bb38-a63e5e0fc0d3']).toBeUndefined(); + expect(ctx['pav']).toBe('http://purl.org/pav/'); + expect(ctx['School Type']).toBe('https://schema.metadatacenter.org/properties/abc'); + }); + + it('should handle missing or null @context gracefully', () => { + const metadata = { '@id': '', 'schema:name': '' }; + expect(() => CedarMetadataHelper.cleanMetadataForSubmission(metadata)).not.toThrow(); + }); + }); +}); diff --git a/src/app/features/metadata/helpers/cedar-metadata.helper.ts b/src/app/features/metadata/helpers/cedar-metadata.helper.ts index 9ee0ecc35..b5bce0cd4 100644 --- a/src/app/features/metadata/helpers/cedar-metadata.helper.ts +++ b/src/app/features/metadata/helpers/cedar-metadata.helper.ts @@ -1,4 +1,21 @@ +import { CedarTemplate } from '../models'; + export class CedarMetadataHelper { + static buildCedarSystemMetadata(template: CedarTemplate): Record { + const now = new Date().toISOString(); + return { + '@id': '', + '@context': template['@context'] ?? {}, + 'schema:isBasedOn': template['@id'] ?? '', + 'schema:name': '', + 'schema:description': '', + 'pav:createdBy': '', + 'oslc:modifiedBy': '', + 'pav:createdOn': now, + 'pav:lastUpdatedOn': now, + }; + } + static ensureProperStructure(items: unknown): Record[] { if (!Array.isArray(items)) return []; @@ -50,4 +67,22 @@ export class CedarMetadataHelper { LDbaseInvestigatorORCID: this.ensureProperStructure([]), }; } + + static cleanMetadataForSubmission(metadata: Record): Record { + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const cleaned: Record = {}; + + for (const [key, value] of Object.entries(metadata)) { + if (uuidRegex.test(key)) continue; + if (key === '@context' && value && typeof value === 'object') { + cleaned[key] = Object.fromEntries( + Object.entries(value as Record).filter(([k]) => !uuidRegex.test(k)) + ); + } else { + cleaned[key] = value; + } + } + + return cleaned; + } } diff --git a/src/app/features/moderation/components/collection-submission-item/collection-submission-item.component.spec.ts b/src/app/features/moderation/components/collection-submission-item/collection-submission-item.component.spec.ts index 612a93311..847f824d9 100644 --- a/src/app/features/moderation/components/collection-submission-item/collection-submission-item.component.spec.ts +++ b/src/app/features/moderation/components/collection-submission-item/collection-submission-item.component.spec.ts @@ -8,6 +8,7 @@ import { CollectionSubmissionWithGuid } from '@osf/shared/models/collections/col import { CollectionsSelectors } from '@osf/shared/stores/collections'; import { DateAgoPipe } from '@shared/pipes/date-ago.pipe'; +import { MOCK_CONTRIBUTOR } from '@testing/mocks/contributors.mock'; import { MOCK_COLLECTION_SUBMISSION_WITH_GUID } from '@testing/mocks/submission.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; @@ -142,4 +143,59 @@ describe('CollectionSubmissionItemComponent', () => { const currentAction = component.currentReviewAction(); expect(currentAction).toBeNull(); }); + + it('should open a new tab with serialized URL on handleNavigation', () => { + const windowOpenSpy = vi.spyOn(window, 'open').mockReturnValue(null); + fixture.componentRef.setInput('submission', mockSubmission); + fixture.detectChanges(); + + component.handleNavigation(); + + expect(mockRouter.createUrlTree).toHaveBeenCalledWith( + ['../', mockSubmission.nodeId], + expect.objectContaining({ queryParams: { status: 'pending', mode: 'moderation' } }) + ); + expect(windowOpenSpy).toHaveBeenCalledWith('/', '_blank'); + }); + + it('should emit loadContributors on handleOpen', () => { + fixture.componentRef.setInput('submission', mockSubmission); + fixture.detectChanges(); + + const outputSpy = vi.fn(); + component.loadContributors.subscribe(outputSpy); + + component.handleOpen(); + + expect(outputSpy).toHaveBeenCalled(); + }); + + it('should return true for hasMoreContributors when loaded count is less than total', () => { + fixture.componentRef.setInput('submission', { + ...mockSubmission, + contributors: [MOCK_CONTRIBUTOR], + totalContributors: 3, + }); + fixture.detectChanges(); + + expect(component.hasMoreContributors()).toBe(true); + }); + + it('should return false for hasMoreContributors when all contributors are loaded', () => { + fixture.componentRef.setInput('submission', { + ...mockSubmission, + contributors: [MOCK_CONTRIBUTOR], + totalContributors: 1, + }); + fixture.detectChanges(); + + expect(component.hasMoreContributors()).toBe(false); + }); + + it('should return false for hasMoreContributors when contributors are not set', () => { + fixture.componentRef.setInput('submission', mockSubmission); + fixture.detectChanges(); + + expect(component.hasMoreContributors()).toBe(false); + }); }); diff --git a/src/app/shared/mappers/collections/collections.mapper.ts b/src/app/shared/mappers/collections/collections.mapper.ts index cd7711c26..d18c2ee96 100644 --- a/src/app/shared/mappers/collections/collections.mapper.ts +++ b/src/app/shared/mappers/collections/collections.mapper.ts @@ -31,6 +31,7 @@ export class CollectionsMapper { return { id: response.id, type: response.type, + iri: response.links.iri, name: replaceBadEncodedChars(response.attributes.name), description: replaceBadEncodedChars(response.attributes.description), advisoryBoard: response.attributes.advisory_board, @@ -71,7 +72,7 @@ export class CollectionsMapper { backgroundColor: response.embeds.brand.data.attributes.background_color, } : null, - requiredMetadataTemplate: response.embeds.required_metadata_template?.data ?? null, + requiredMetadataTemplate: null, }; } diff --git a/src/app/shared/models/collections/collections-json-api.model.ts b/src/app/shared/models/collections/collections-json-api.model.ts index 9dce2537f..fc6ce11b3 100644 --- a/src/app/shared/models/collections/collections-json-api.model.ts +++ b/src/app/shared/models/collections/collections-json-api.model.ts @@ -1,4 +1,3 @@ -import { CedarMetadataDataTemplateJsonApi } from '@osf/features/metadata/models'; import { CollectionSubmissionReviewState } from '@osf/shared/enums/collection-submission-review-state.enum'; import { BrandDataJsonApi } from '../brand/brand.json-api.model'; @@ -10,14 +9,15 @@ import { UserDataErrorResponseJsonApi } from '../user/user-json-api.model'; export interface CollectionProviderResponseJsonApi { id: string; type: string; + links: { + iri: string; + self: string; + }; attributes: CollectionsProviderAttributesJsonApi; embeds: { brand: { data?: BrandDataJsonApi; }; - required_metadata_template?: { - data?: CedarMetadataDataTemplateJsonApi | null; - }; }; relationships: { primary_collection: { @@ -26,6 +26,12 @@ export interface CollectionProviderResponseJsonApi { type: string; }; }; + required_metadata_template?: { + data?: { + id: string; + type: string; + } | null; + }; }; } diff --git a/src/app/shared/models/collections/collections.model.ts b/src/app/shared/models/collections/collections.model.ts index ebecbbe80..71c197222 100644 --- a/src/app/shared/models/collections/collections.model.ts +++ b/src/app/shared/models/collections/collections.model.ts @@ -8,6 +8,7 @@ import { ProjectModel } from '../projects/projects.model'; import { BaseProviderModel } from '../provider/provider.model'; export interface CollectionProvider extends BaseProviderModel { + iri?: string; assets: { style?: string; squareColorTransparent?: string; diff --git a/src/app/shared/services/collections.service.ts b/src/app/shared/services/collections.service.ts index 2f2bc8256..ba97b566e 100644 --- a/src/app/shared/services/collections.service.ts +++ b/src/app/shared/services/collections.service.ts @@ -41,6 +41,7 @@ import { ReviewActionPayloadJsonApi } from '../models/review-action/review-actio import { SetTotalSubmissions } from '../stores/collections/collections.actions'; import { JsonApiService } from './json-api.service'; +import { MetadataService } from './metadata.service'; @Injectable({ providedIn: 'root', @@ -48,6 +49,7 @@ import { JsonApiService } from './json-api.service'; export class CollectionsService { private readonly jsonApiService = inject(JsonApiService); private readonly environment = inject(ENVIRONMENT); + private readonly metadataService = inject(MetadataService); get apiUrl() { return `${this.environment.apiDomainUrl}/v2`; @@ -56,11 +58,22 @@ export class CollectionsService { private actions = createDispatchMap({ setTotalSubmissions: SetTotalSubmissions }); getCollectionProvider(collectionName: string): Observable { - const url = `${this.apiUrl}/providers/collections/${collectionName}/?embed=brand,required_metadata_template`; + const url = `${this.apiUrl}/providers/collections/${collectionName}/?embed=brand`; - return this.jsonApiService - .get>(url) - .pipe(map((response) => CollectionsMapper.fromGetCollectionProviderResponse(response.data))); + return this.jsonApiService.get>(url).pipe( + switchMap((response) => { + const provider = CollectionsMapper.fromGetCollectionProviderResponse(response.data); + const templateId = response.data.relationships.required_metadata_template?.data?.id; + + if (!templateId) { + return of(provider); + } + + return this.metadataService + .getCedarMetadataTemplateDetail(templateId) + .pipe(map((template) => ({ ...provider, requiredMetadataTemplate: template }))); + }) + ); } getCollectionDetails(collectionId: string): Observable { diff --git a/src/app/shared/services/metadata.service.ts b/src/app/shared/services/metadata.service.ts index 82c1bd357..6488cf11d 100644 --- a/src/app/shared/services/metadata.service.ts +++ b/src/app/shared/services/metadata.service.ts @@ -6,6 +6,7 @@ import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { CedarRecordsMapper, MetadataMapper, RorMapper } from '@osf/features/metadata/mappers'; import { + CedarMetadataDataTemplateJsonApi, CedarMetadataRecord, CedarMetadataRecordJsonApi, CedarMetadataTemplateJsonApi, @@ -21,6 +22,7 @@ import { } from '@osf/features/metadata/models'; import { ResourceType } from '../enums/resource-type.enum'; +import { JsonApiResponse } from '../models/common/json-api.model'; import { IdentifierModel } from '../models/identifiers/identifier.model'; import { LicenseOptions } from '../models/license/license.model'; import { BaseNodeAttributesJsonApi } from '../models/nodes/base-node-attributes-json-api.model'; @@ -102,6 +104,14 @@ export class MetadataService { ); } + getCedarMetadataTemplateDetail(templateId: string): Observable { + return this.jsonApiService + .get< + JsonApiResponse + >(`${this.apiDomainUrl}/_/cedar_metadata_templates/${templateId}/`) + .pipe(map((response) => response.data)); + } + getMetadataCedarRecords( resourceId: string, resourceType: ResourceType, diff --git a/src/app/shared/stores/global-search/global-search.state.ts b/src/app/shared/stores/global-search/global-search.state.ts index b20d061b4..bb94a2461 100644 --- a/src/app/shared/stores/global-search/global-search.state.ts +++ b/src/app/shared/stores/global-search/global-search.state.ts @@ -276,8 +276,15 @@ export class GlobalSearchState { private updateResourcesState(ctx: StateContext, response: ResourcesData) { const { extraFilters } = ctx.getState(); - const apiFilterKeys = new Set(response.filters.map((f) => f.key)); - const merged = [...response.filters, ...extraFilters.filter((f) => !apiFilterKeys.has(f.key))]; + const seenKeys = new Set(response.filters.map((f) => f.key)); + const merged = [ + ...response.filters, + ...extraFilters.filter((f) => { + if (seenKeys.has(f.key)) return false; + seenKeys.add(f.key); + return true; + }), + ]; ctx.patchState({ resources: { data: response.resources, isLoading: false, error: null },