diff --git a/src/app/features/collections/components/add-to-collection/add-to-collection-confirmation-dialog/add-to-collection-confirmation-dialog.component.spec.ts b/src/app/features/collections/components/add-to-collection/add-to-collection-confirmation-dialog/add-to-collection-confirmation-dialog.component.spec.ts index 31cb61995..4bd76944d 100644 --- a/src/app/features/collections/components/add-to-collection/add-to-collection-confirmation-dialog/add-to-collection-confirmation-dialog.component.spec.ts +++ b/src/app/features/collections/components/add-to-collection/add-to-collection-confirmation-dialog/add-to-collection-confirmation-dialog.component.spec.ts @@ -9,7 +9,11 @@ import { of, throwError } from 'rxjs'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { CreateCollectionSubmission } from '@osf/features/collections/store/add-to-collection/add-to-collection.actions'; +import { CedarMetadataAttributes, CedarRecordDataBinding } from '@osf/features/metadata/models'; +import { CreateCedarMetadataRecord } from '@osf/features/metadata/store'; import { UpdateProjectPublicStatus } from '@osf/features/project/overview/store'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { CollectionSubmissionPayload } from '@osf/shared/models/collections/collection-submission-payload.model'; import { ToastService } from '@osf/shared/services/toast.service'; import { provideOSFCore } from '@testing/osf.testing.provider'; @@ -19,20 +23,39 @@ import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast import { AddToCollectionConfirmationDialogComponent } from './add-to-collection-confirmation-dialog.component'; +const MOCK_CEDAR_DATA: CedarRecordDataBinding = { + data: { '@context': {} } as CedarMetadataAttributes, + id: 'template-1', + isPublished: true, +}; + describe('AddToCollectionConfirmationDialogComponent', () => { let component: AddToCollectionConfirmationDialogComponent; let fixture: ComponentFixture; let store: Store; let dialogRef: DynamicDialogRef; let toastService: ToastServiceMockType; - let dialogConfig: { data: { payload?: unknown; project?: { id: string; isPublic: boolean } } }; + let dialogConfig: { + data: { + payload?: CollectionSubmissionPayload; + project?: { id: string; isPublic: boolean }; + cedarData?: CedarRecordDataBinding | null; + }; + }; + + const MOCK_PAYLOAD: CollectionSubmissionPayload = { + collectionId: 'collection-1', + projectId: 'project-1', + userId: 'user-1', + }; beforeEach(() => { toastService = ToastServiceMock.simple(); dialogConfig = { data: { - payload: { title: 'Submission' }, + payload: MOCK_PAYLOAD, project: { id: 'project-1', isPublic: false }, + cedarData: null, }, }; @@ -69,13 +92,14 @@ describe('AddToCollectionConfirmationDialogComponent', () => { expect(toastService.showSuccess).not.toHaveBeenCalled(); }); - it('should update project public status and create submission when project is private', () => { + it('should update project public status then create submission when project is private and no Cedar data', () => { vi.spyOn(store, 'dispatch').mockReturnValue(of(void 0)); component.handleAddToCollectionConfirm(); expect(store.dispatch).toHaveBeenCalledWith(new UpdateProjectPublicStatus([{ id: 'project-1', public: true }])); - expect(store.dispatch).toHaveBeenCalledWith(new CreateCollectionSubmission({ title: 'Submission' } as any)); + expect(store.dispatch).toHaveBeenCalledWith(new CreateCollectionSubmission(MOCK_PAYLOAD)); + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(CreateCedarMetadataRecord)); expect(dialogRef.close).toHaveBeenCalledWith(true); expect(toastService.showSuccess).toHaveBeenCalledWith('collections.addToCollection.confirmationDialogToastMessage'); expect(component.isSubmitting()).toBe(false); @@ -87,11 +111,34 @@ describe('AddToCollectionConfirmationDialogComponent', () => { component.handleAddToCollectionConfirm(); - expect(store.dispatch).toHaveBeenCalledWith(new CreateCollectionSubmission({ title: 'Submission' } as any)); + expect(store.dispatch).toHaveBeenCalledWith(new CreateCollectionSubmission(MOCK_PAYLOAD)); expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(UpdateProjectPublicStatus)); expect(dialogRef.close).toHaveBeenCalledWith(true); }); + it('should create Cedar record before submission when cedarData is present', () => { + dialogConfig.data.cedarData = MOCK_CEDAR_DATA; + vi.spyOn(store, 'dispatch').mockReturnValue(of(void 0)); + + component.handleAddToCollectionConfirm(); + + expect(store.dispatch).toHaveBeenCalledWith( + new CreateCedarMetadataRecord(MOCK_CEDAR_DATA, 'project-1', ResourceType.Project) + ); + expect(store.dispatch).toHaveBeenCalledWith(new CreateCollectionSubmission(MOCK_PAYLOAD)); + expect(dialogRef.close).toHaveBeenCalledWith(true); + }); + + it('should not create Cedar record when cedarData is null', () => { + dialogConfig.data.cedarData = null; + vi.spyOn(store, 'dispatch').mockReturnValue(of(void 0)); + + component.handleAddToCollectionConfirm(); + + expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(CreateCedarMetadataRecord)); + expect(store.dispatch).toHaveBeenCalledWith(new CreateCollectionSubmission(MOCK_PAYLOAD)); + }); + it('should reset submitting state on error', () => { vi.spyOn(store, 'dispatch').mockImplementation((action) => { if (action instanceof CreateCollectionSubmission) { @@ -106,4 +153,20 @@ describe('AddToCollectionConfirmationDialogComponent', () => { expect(dialogRef.close).not.toHaveBeenCalled(); expect(toastService.showSuccess).not.toHaveBeenCalled(); }); + + it('should reset submitting state on Cedar record creation error', () => { + dialogConfig.data.cedarData = MOCK_CEDAR_DATA; + vi.spyOn(store, 'dispatch').mockImplementation((action) => { + if (action instanceof CreateCedarMetadataRecord) { + return throwError(() => new Error('cedar fail')); + } + return of(void 0); + }); + + component.handleAddToCollectionConfirm(); + + expect(component.isSubmitting()).toBe(false); + expect(dialogRef.close).not.toHaveBeenCalled(); + expect(toastService.showSuccess).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/features/collections/components/add-to-collection/add-to-collection-confirmation-dialog/add-to-collection-confirmation-dialog.component.ts b/src/app/features/collections/components/add-to-collection/add-to-collection-confirmation-dialog/add-to-collection-confirmation-dialog.component.ts index 4ed620f02..400437df7 100644 --- a/src/app/features/collections/components/add-to-collection/add-to-collection-confirmation-dialog/add-to-collection-confirmation-dialog.component.ts +++ b/src/app/features/collections/components/add-to-collection/add-to-collection-confirmation-dialog/add-to-collection-confirmation-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 { forkJoin, of } from 'rxjs'; +import { Observable, of, switchMap } from 'rxjs'; import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { CreateCollectionSubmission } from '@osf/features/collections/store/add-to-collection/add-to-collection.actions'; +import { CedarRecordDataBinding } from '@osf/features/metadata/models'; +import { CreateCedarMetadataRecord } from '@osf/features/metadata/store'; import { UpdateProjectPublicStatus } from '@osf/features/project/overview/store'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { ToastService } from '@osf/shared/services/toast.service'; @Component({ @@ -30,26 +33,33 @@ export class AddToCollectionConfirmationDialogComponent { actions = createDispatchMap({ createCollectionSubmission: CreateCollectionSubmission, updateProjectPublicStatus: UpdateProjectPublicStatus, + createCedarRecord: CreateCedarMetadataRecord, }); handleAddToCollectionConfirm(): void { const payload = this.config.data.payload; const project = this.config.data.project; + const cedarData = this.config.data.cedarData as CedarRecordDataBinding | null | undefined; if (!payload || !project) return; this.isSubmitting.set(true); const projectPayload = [{ id: project.id as string, public: true }]; - const updatePublicStatus$ = project.isPublic ? of(null) : this.actions.updateProjectPublicStatus(projectPayload); + const updatePublicStatus$: Observable = project.isPublic + ? of(null) + : this.actions.updateProjectPublicStatus(projectPayload); - const createSubmission$ = this.actions.createCollectionSubmission(payload); + const createCedar$: Observable = cedarData + ? this.actions.createCedarRecord(cedarData, project.id as string, ResourceType.Project) + : of(null); - forkJoin({ - publicStatusUpdate: updatePublicStatus$, - collectionSubmission: createSubmission$, - }) - .pipe(takeUntilDestroyed(this.destroyRef)) + updatePublicStatus$ + .pipe( + switchMap(() => createCedar$), + switchMap(() => this.actions.createCollectionSubmission(payload)), + takeUntilDestroyed(this.destroyRef) + ) .subscribe({ next: () => { this.isSubmitting.set(false); 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 41cf077d5..d76299fba 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,7 +48,11 @@

{{ 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 9f49bfde0..63f25e48d 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,5 +1,7 @@ import { MockComponents, MockProvider } from 'ng-mocks'; +import { of, Subject, throwError } from 'rxjs'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormGroup } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; @@ -10,8 +12,13 @@ import { ProjectContributorsStepComponent } from '@osf/features/collections/comp 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 { BrandService } from '@osf/shared/services/brand.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; +import { HeaderStyleService } from '@osf/shared/services/header-style.service'; +import { LoaderService } from '@osf/shared/services/loader.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { CollectionsSelectors } from '@shared/stores/collections'; import { ProjectsSelectors } from '@shared/stores/projects/projects.selectors'; @@ -20,65 +27,105 @@ 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 } from '@testing/providers/brand-service.mock'; +import { + CustomDialogServiceMockBuilder, + CustomDialogServiceMockType, +} from '@testing/providers/custom-dialog-provider.mock'; +import { HeaderStyleServiceMock } 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 { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; +import { ToastServiceMock } from '@testing/providers/toast-provider.mock'; + +import { AddToCollectionSelectors } from '../../store/add-to-collection'; import { AddToCollectionComponent } from './add-to-collection.component'; -describe('AddToCollectionComponent', () => { - let component: AddToCollectionComponent; - let fixture: ComponentFixture; - let mockRouter: ReturnType; - let mockActivatedRoute: ReturnType; - let mockCustomDialogService: ReturnType; - - const mockCollectionProvider = MOCK_PROVIDER; - - beforeEach(() => { - mockRouter = RouterMockBuilder.create().build(); - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: null }).build(); - mockCustomDialogService = CustomDialogServiceMockBuilder.create().build(); - - TestBed.configureTestingModule({ - imports: [ - AddToCollectionComponent, - ...MockComponents( - LoadingSpinnerComponent, - SelectProjectStepComponent, - ProjectMetadataStepComponent, - ProjectContributorsStepComponent, - CollectionMetadataStepComponent - ), - ], - 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: ProjectsSelectors.getSelectedProject, value: MOCK_PROJECT }, - { selector: UserSelectors.getCurrentUser, value: MOCK_USER }, - ], - }), - ], - }); +const mockCollectionProvider = MOCK_PROVIDER; + +const DEFAULT_SIGNALS: SignalOverride[] = [ + { 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: UserSelectors.getActiveFlags, value: [] }, + { selector: MetadataSelectors.getCedarRecords, value: [] }, + { selector: AddToCollectionSelectors.getCurrentCollectionSubmission, value: null }, +]; + +interface SetupOptions { + routeParams?: Record; + selectorOverrides?: SignalOverride[]; +} + +function setup(options: SetupOptions = {}) { + const { routeParams = { id: null }, selectorOverrides } = options; + + const mockRouter = RouterMockBuilder.create().build(); + const mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams(routeParams).build(); + const mockCustomDialogService = CustomDialogServiceMockBuilder.create().withDefaultOpen().build(); + const mockToastService = ToastServiceMock.simple(); + const mockLoaderService = new LoaderServiceMock(); + const mockBrandService = BrandServiceMock.simple(); + const mockHeaderStyleService = HeaderStyleServiceMock.simple(); - fixture = TestBed.createComponent(AddToCollectionComponent); - component = fixture.componentInstance; - fixture.detectChanges(); + TestBed.configureTestingModule({ + imports: [ + AddToCollectionComponent, + ...MockComponents( + LoadingSpinnerComponent, + SelectProjectStepComponent, + ProjectMetadataStepComponent, + ProjectContributorsStepComponent, + CollectionMetadataStepComponent + ), + ], + providers: [ + provideOSFCore(), + MockProvider(ActivatedRoute, mockActivatedRoute), + MockProvider(Router, mockRouter), + MockProvider(CustomDialogService, mockCustomDialogService), + MockProvider(ToastService, mockToastService), + MockProvider(LoaderService, mockLoaderService), + MockProvider(BrandService, mockBrandService), + MockProvider(HeaderStyleService, mockHeaderStyleService), + provideMockStore({ + signals: mergeSignalOverrides(DEFAULT_SIGNALS, selectorOverrides), + }), + ], }); + const fixture: ComponentFixture = TestBed.createComponent(AddToCollectionComponent); + const component = fixture.componentInstance; + fixture.detectChanges(); + + const dialogService = TestBed.inject(CustomDialogService) as unknown as CustomDialogServiceMockType; + + return { + component, + fixture, + mockRouter, + mockActivatedRoute, + mockCustomDialogService, + dialogService, + mockToastService, + mockLoaderService, + mockBrandService, + mockHeaderStyleService, + }; +} + +describe('AddToCollectionComponent', () => { it('should create', () => { + const { component } = setup(); expect(component).toBeTruthy(); }); - it('should initialize with default values', () => { + it('should initialize with default signal values', () => { + const { component } = setup(); expect(component.stepperActiveValue()).toBe(AddToCollectionSteps.SelectProject); expect(component.projectMetadataSaved()).toBe(false); expect(component.projectContributorsSaved()).toBe(false); @@ -86,7 +133,18 @@ describe('AddToCollectionComponent', () => { expect(component.allowNavigation()).toBe(false); }); + it('should navigate to /not-found when providerId is absent from route', () => { + const { mockRouter } = setup({ routeParams: {} }); + expect(mockRouter.navigate).toHaveBeenCalledWith(['/not-found']); + }); + + it('should set providerId and dispatch getCollectionProvider when providerId is present', () => { + const { component } = setup({ routeParams: { providerId: 'provider-1' } }); + expect(component.providerId()).toBe('provider-1'); + }); + it('should handle project selection', () => { + const { component } = setup(); component.handleProjectSelected(); expect(component.projectContributorsSaved()).toBe(false); @@ -95,6 +153,7 @@ describe('AddToCollectionComponent', () => { }); it('should handle step change', () => { + const { component } = setup(); const newStep = AddToCollectionSteps.ProjectMetadata; component.handleChangeStep(newStep); @@ -102,12 +161,14 @@ describe('AddToCollectionComponent', () => { }); it('should handle project metadata saved', () => { + const { component } = setup(); component.handleProjectMetadataSaved(); expect(component.projectMetadataSaved()).toBe(true); }); it('should handle contributors saved', () => { + const { component } = setup(); component.handleContributorsSaved(); expect(component.stepperActiveValue()).toBe(AddToCollectionSteps.CollectionMetadata); @@ -115,6 +176,7 @@ describe('AddToCollectionComponent', () => { }); it('should handle collection metadata saved', () => { + const { component } = setup(); const mockForm = new FormGroup({}); component.handleCollectionMetadataSaved(mockForm); @@ -123,25 +185,151 @@ describe('AddToCollectionComponent', () => { expect(component.stepperActiveValue()).toBe(AddToCollectionSteps.Complete); }); + it('should handle cedar data saved', () => { + const { component } = setup(); + const mockCedarData: CedarRecordDataBinding = { + data: {} as CedarRecordDataBinding['data'], + id: 'template-123', + isPublished: false, + }; + component.handleCedarDataSaved(mockCedarData); + + expect(component.pendingCedarData()).toEqual(mockCedarData); + expect(component.collectionMetadataSaved()).toBe(true); + expect(component.stepperActiveValue()).toBe(AddToCollectionSteps.Complete); + }); + it('should have actions defined', () => { + const { component } = setup(); expect(component.actions).toBeDefined(); expect(component.actions.getCollectionProvider).toBeDefined(); expect(component.actions.clearAddToCollectionState).toBeDefined(); }); - it('should handle loading state', () => { + it('should reflect loading state from store', () => { + const { component } = setup(); expect(component.isProviderLoading()).toBe(false); }); - it('should have collection provider data', () => { + it('should expose collection provider from store', () => { + const { component } = setup(); expect(component.collectionProvider()).toEqual(mockCollectionProvider); }); - it('should have selected project data', () => { + it('should expose selected project from store', () => { + const { component } = setup(); expect(component.selectedProject()).toEqual(MOCK_PROJECT); }); - it('should have current user data', () => { + it('should expose current user from store', () => { + const { component } = setup(); expect(component.currentUser()).toEqual(MOCK_USER); }); + + it('should return true from canDeactivate when allowNavigation is true', () => { + const { component } = setup(); + component.allowNavigation.set(true); + + expect(component.canDeactivate()).toBe(true); + }); + + it('should return false from canDeactivate when there are unsaved changes', () => { + const { component } = setup(); + component.projectMetadataSaved.set(true); + + expect(component.canDeactivate()).toBe(false); + }); + + it('should return true from canDeactivate when no unsaved changes', () => { + const { component } = setup({ + selectorOverrides: [{ selector: ProjectsSelectors.getSelectedProject, value: null }], + }); + + expect(component.canDeactivate()).toBe(true); + }); + + it('should prevent page unload when there are unsaved changes', () => { + const { component } = setup(); + component.projectMetadataSaved.set(true); + const mockEvent = { preventDefault: vi.fn() } as unknown as BeforeUnloadEvent; + + const result = component.onBeforeUnload(mockEvent); + + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it('should allow page unload when allowNavigation is true', () => { + const { component } = setup(); + component.allowNavigation.set(true); + const mockEvent = { preventDefault: vi.fn() } as unknown as BeforeUnloadEvent; + + const result = component.onBeforeUnload(mockEvent); + + expect(mockEvent.preventDefault).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + + it('should open confirmation dialog in new submission mode', () => { + const { component, dialogService } = setup(); + component.handleAddToCollection(); + + expect(dialogService.open).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ header: 'collections.addToCollection.confirmationDialogHeader' }) + ); + }); + + it('should update submission and navigate on success in edit mode', () => { + const { component, mockToastService, mockLoaderService, mockRouter } = setup({ + routeParams: { id: 'project-1', providerId: 'provider-1' }, + }); + vi.spyOn(component.actions, 'updateCollectionSubmission').mockReturnValue(of(void 0)); + + component.handleAddToCollection(); + + expect(mockLoaderService.show).toHaveBeenCalled(); + expect(mockToastService.showSuccess).toHaveBeenCalledWith( + 'collections.addToCollection.confirmationDialogToastMessage' + ); + expect(component.allowNavigation()).toBe(true); + expect(mockRouter.navigate).toHaveBeenCalledWith(['project-1', 'overview']); + }); + + it('should show error toast when update fails in edit mode', () => { + const { component, mockToastService } = setup({ + routeParams: { id: 'project-1', providerId: 'provider-1' }, + }); + vi.spyOn(component.actions, 'updateCollectionSubmission').mockReturnValue(throwError(() => new Error('fail'))); + + component.handleAddToCollection(); + + expect(mockToastService.showError).toHaveBeenCalledWith('collections.addToCollection.updateError'); + }); + + it('should not open remove dialog when selected project is missing', () => { + const { component, dialogService } = setup({ + selectorOverrides: [{ selector: ProjectsSelectors.getSelectedProject, value: null }], + }); + component.handleRemoveFromCollection(); + + expect(dialogService.open).not.toHaveBeenCalled(); + }); + + it('should dispatch deleteCollectionSubmission and navigate on successful removal', () => { + const onCloseSubject = new Subject<{ confirmed: boolean; comment?: string }>(); + const { component, dialogService, mockToastService, mockLoaderService, mockRouter } = setup(); + + dialogService.open = vi.fn().mockReturnValue({ onClose: onCloseSubject.asObservable() }); + vi.spyOn(component.actions, 'deleteCollectionSubmission').mockReturnValue(of(void 0)); + + component.handleRemoveFromCollection(); + onCloseSubject.next({ confirmed: true, comment: '' }); + + expect(mockLoaderService.show).toHaveBeenCalled(); + expect(mockToastService.showSuccess).toHaveBeenCalledWith('collections.removeDialog.success'); + expect(mockLoaderService.hide).toHaveBeenCalled(); + expect(component.allowNavigation()).toBe(true); + expect(mockRouter.navigate).toHaveBeenCalledWith(['project-1', 'overview']); + }); }); 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 15e4fbcbb..af90d8753 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 @@ -5,7 +5,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Stepper } from 'primeng/stepper'; -import { filter, map, Observable, of, switchMap } from 'rxjs'; +import { filter, finalize, map, Observable, of, switchMap } from 'rxjs'; import { isPlatformBrowser } from '@angular/common'; import { @@ -23,9 +23,18 @@ 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'; @@ -34,6 +43,7 @@ import { LoaderService } from '@osf/shared/services/loader.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { CollectionsSelectors, GetCollectionProvider } from '@osf/shared/stores/collections'; import { ProjectsSelectors, SetSelectedProject } from '@osf/shared/stores/projects'; +import { COLLECTION_SUBMISSION_WITH_CEDAR } from '@shared/constants/feature-flags.const'; import { AddToCollectionSteps } from '../../enums'; import { RemoveCollectionSubmissionPayload } from '../../models/remove-collection-submission-payload.model'; @@ -81,6 +91,7 @@ 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) @@ -92,15 +103,19 @@ 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); + activeFlags = select(UserSelectors.getActiveFlags); 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); @@ -110,14 +125,26 @@ export class AddToCollectionComponent implements CanDeactivateComponent { isCollectionMetadataDisabled = computed( () => !this.selectedProject() || !this.projectMetadataSaved() || !this.projectContributorsSaved() ); + isCedarMode = computed( + () => this.activeFlags().includes(COLLECTION_SUBMISSION_WITH_CEDAR) && !!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; + }); - actions = createDispatchMap({ + readonly actions = createDispatchMap({ getCollectionProvider: GetCollectionProvider, clearAddToCollectionState: ClearAddToCollectionState, updateCollectionSubmission: UpdateCollectionSubmission, deleteCollectionSubmission: RemoveCollectionSubmission, setSelectedProject: SetSelectedProject, getCurrentCollectionSubmission: GetCurrentCollectionSubmission, + getCedarRecords: GetCedarMetadataRecords, + createCedarRecord: CreateCedarMetadataRecord, + updateCedarRecord: UpdateCedarMetadataRecord, }); showRemoveButton = computed( @@ -133,7 +160,10 @@ export class AddToCollectionComponent implements CanDeactivateComponent { } @HostListener('window:beforeunload', ['$event']) - onBeforeUnload($event: BeforeUnloadEvent): boolean { + onBeforeUnload($event: BeforeUnloadEvent): boolean | undefined { + if (this.allowNavigation() || !this.hasUnsavedChanges()) { + return undefined; + } $event.preventDefault(); return false; } @@ -171,12 +201,18 @@ 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.collectionMetadataForm.value || {}, userId: this.currentUser()?.id || '', + collectionMetadata: this.isCedarMode() ? {} : this.collectionMetadataForm.value || {}, }; const isEditMode = this.isEditMode(); @@ -184,30 +220,46 @@ export class AddToCollectionComponent implements CanDeactivateComponent { if (isEditMode) { this.loaderService.show(); - this.actions - .updateCollectionSubmission(payload) - .pipe(takeUntilDestroyed(this.destroyRef)) + this.saveCedarRecordIfNeeded() + .pipe( + switchMap(() => this.actions.updateCollectionSubmission(payload)), + finalize(() => this.loaderService.hide()), + takeUntilDestroyed(this.destroyRef) + ) .subscribe({ next: () => { this.toastService.showSuccess('collections.addToCollection.confirmationDialogToastMessage'); this.allowNavigation.set(true); this.router.navigate([this.selectedProject()?.id, 'overview']); }, + error: () => { + this.toastService.showError('collections.addToCollection.updateError'); + }, }); } else { this.customDialogService .open(AddToCollectionConfirmationDialogComponent, { header: 'collections.addToCollection.confirmationDialogHeader', width: '500px', - data: { payload, project: this.selectedProject() }, + data: { + payload, + project: this.selectedProject(), + cedarData: this.pendingCedarData(), + }, }) .onClose.pipe( filter((res) => !!res), + switchMap(() => this.saveCedarRecordIfNeeded()), takeUntilDestroyed(this.destroyRef) ) - .subscribe(() => { - this.allowNavigation.set(true); - this.router.navigate([this.selectedProject()?.id, 'overview']); + .subscribe({ + next: () => { + this.allowNavigation.set(true); + this.router.navigate([this.selectedProject()?.id, 'overview']); + }, + error: () => { + this.toastService.showError('collections.addToCollection.updateError'); + }, }); } } @@ -228,26 +280,40 @@ export class AddToCollectionComponent implements CanDeactivateComponent { .onClose.pipe( filter((res: RemoveFromCollectionDialogResult) => res?.confirmed), switchMap((res) => { - const payload: RemoveCollectionSubmissionPayload = { + const removePayload: RemoveCollectionSubmissionPayload = { projectId, collectionId, comment: res?.comment || '', }; - - return this.actions.deleteCollectionSubmission(payload); + this.loaderService.show(); + return this.actions.deleteCollectionSubmission(removePayload); }), takeUntilDestroyed(this.destroyRef) ) .subscribe({ next: () => { this.toastService.showSuccess('collections.removeDialog.success'); - this.loaderService.show(); + this.loaderService.hide(); 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) { @@ -286,6 +352,14 @@ 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 f10094962..0b0cd6498 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,14 +11,25 @@

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

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

{{ filterEntry.labelKey | translate }}

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

{{ filterEntry.labelKey | translate }}

-

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

-
+

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

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

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

-
- @for (filterEntry of availableFilterEntries(); track filterEntry.key) { -
- - + @if (isCedarMode()) { + @if (cedarTemplate()) { +
+
+ +
+ + +
+ } @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 96c1f74cd..4e6ebf6f4 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 @@ -6,18 +6,27 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; 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'; import { CollectionMetadataStepComponent } from './collection-metadata-step.component'; -describe.skip('CollectionMetadataStepComponent', () => { +describe('CollectionMetadataStepComponent', () => { let component: CollectionMetadataStepComponent; let fixture: ComponentFixture; - beforeEach(() => { + function setup( + options: { + isCedarMode?: boolean; + cedarTemplate?: CedarMetadataDataTemplateJsonApi | null; + existingCedarRecord?: CedarMetadataRecordData | null; + } = {} + ) { TestBed.configureTestingModule({ imports: [CollectionMetadataStepComponent, MockComponents(StepPanel, Step, StepItem)], providers: [ @@ -27,6 +36,7 @@ describe.skip('CollectionMetadataStepComponent', () => { { selector: CollectionsSelectors.getCollectionProvider, value: null }, { selector: CollectionsSelectors.getCollectionProviderLoading, value: false }, { selector: CollectionsSelectors.getAllFiltersOptions, value: {} }, + { selector: AddToCollectionSelectors.getCurrentCollectionSubmission, value: null }, ], }), ], @@ -40,7 +50,21 @@ describe.skip('CollectionMetadataStepComponent', () => { fixture.componentRef.setInput('isDisabled', false); fixture.componentRef.setInput('primaryCollectionId', 'test-collection-id'); + if (options.isCedarMode !== undefined) { + fixture.componentRef.setInput('isCedarMode', options.isCedarMode); + } + if (options.cedarTemplate !== undefined) { + fixture.componentRef.setInput('cedarTemplate', options.cedarTemplate); + } + if (options.existingCedarRecord !== undefined) { + fixture.componentRef.setInput('existingCedarRecord', options.existingCedarRecord); + } + fixture.detectChanges(); + } + + beforeEach(() => { + setup(); }); it('should create', () => { @@ -51,9 +75,14 @@ describe.skip('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', () => { + it('should default isCedarMode to false', () => { + expect(component.isCedarMode()).toBe(false); + }); + + it('should handle save metadata in filter mode', () => { const mockForm = new FormGroup({}); component.collectionMetadataForm.set(mockForm); @@ -87,7 +116,7 @@ describe.skip('CollectionMetadataStepComponent', () => { expect(navigateSpy).toHaveBeenCalledWith(component.targetStepValue()); }); - it('should handle discard changes', () => { + it('should handle discard changes in filter mode', () => { const mockForm = new FormGroup({}); component.collectionMetadataForm.set(mockForm); component.collectionMetadataSaved.set(true); @@ -102,11 +131,6 @@ describe.skip('CollectionMetadataStepComponent', () => { expect(component.collectionMetadataSaved()).toBe(false); }); - it('should have actions defined', () => { - expect(component.actions).toBeDefined(); - expect(component.actions.getCollectionDetails).toBeDefined(); - }); - it('should handle different input values', () => { fixture.componentRef.setInput('stepperActiveValue', 2); fixture.componentRef.setInput('targetStepValue', 3); @@ -117,4 +141,94 @@ describe.skip('CollectionMetadataStepComponent', () => { expect(component.targetStepValue()).toBe(3); expect(component.isDisabled()).toBe(true); }); + + describe('CEDAR mode', () => { + beforeEach(() => { + setup({ isCedarMode: true, cedarTemplate: 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 acb6a1d0b..9b6dfa057 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,13 +7,32 @@ import { Select } from 'primeng/select'; import { Step, StepItem, StepPanel } from 'primeng/stepper'; import { Tooltip } from 'primeng/tooltip'; -import { ChangeDetectionStrategy, Component, computed, effect, input, output, signal } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + computed, + CUSTOM_ELEMENTS_SCHEMA, + effect, + ElementRef, + input, + output, + signal, + viewChild, + ViewEncapsulation, +} 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'; @@ -23,6 +42,8 @@ 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; @@ -45,16 +66,27 @@ 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>({}); - actions = createDispatchMap({ getCollectionDetails: GetCollectionDetails }); + cedarConfig = CEDAR_CONFIG; + cedarViewerConfig = CEDAR_VIEWER_CONFIG; + + cedarEditor = viewChild>('cedarEditor'); + cedarViewer = viewChild>('cedarViewer'); + + private readonly actions = createDispatchMap({ getCollectionDetails: GetCollectionDetails }); constructor() { this.setupEffects(); @@ -65,6 +97,19 @@ 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(); @@ -85,6 +130,40 @@ 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.metadataSaved.emit(this.collectionMetadataForm()); + 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 = {}; @@ -115,9 +194,21 @@ 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) { + if (filterEntries.length && !this.isCedarMode()) { this.buildCollectionMetadataForm(); } }); @@ -133,7 +224,8 @@ export class CollectionMetadataStepComponent { form.controls && Object.keys(form.controls).length > 0 && filterEntries.length > 0 && - !alreadyPopulated + !alreadyPopulated && + !this.isCedarMode() ) { this.populateFormFromSubmission(submission.submission); this.formPopulatedFromSubmission.set(true); @@ -142,8 +234,10 @@ export class CollectionMetadataStepComponent { effect(() => { if (!this.collectionMetadataSaved() && this.stepperActiveValue() !== AddToCollectionSteps.CollectionMetadata) { - this.collectionMetadataForm().reset(); - this.formPopulatedFromSubmission.set(false); + if (!this.isCedarMode()) { + this.collectionMetadataForm().reset(); + this.formPopulatedFromSubmission.set(false); + } } }); } diff --git a/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.html b/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.html index 5b9f995e3..01e9f6508 100644 --- a/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.html +++ b/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.html @@ -95,23 +95,27 @@

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

@if (license.requiredFields.length) {
-
- - + + +
+ } + @if (license.requiredFields.includes('copyrightHolders')) { + -
- + }
} diff --git a/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.ts b/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.ts index 878893086..4f38ab5dc 100644 --- a/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.ts +++ b/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.ts @@ -238,7 +238,7 @@ export class ProjectMetadataStepComponent { this.formService.updateLicenseValidators(this.projectMetadataForm, license); }); } - this.populateFormFromProject(); + untracked(() => this.populateFormFromProject()); }); effect(() => { diff --git a/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.html b/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.html index a17d3deab..ceae12d81 100644 --- a/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.html +++ b/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.html @@ -26,6 +26,7 @@

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

{{ collectionProvider()?
- + @if (useShareTroveSearch()) { + @if (defaultSearchFiltersInitialized()) { + + } + } @else { + + }
} @else { 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 09693f727..0d590b06f 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 @@ -1,129 +1,310 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; +import { Mock } from 'vitest'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; +import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { UserSelectors } from '@core/store/user'; +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 { provideOSFCore } from '@testing/osf.testing.provider'; import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; +import { ToastServiceMock } from '@testing/providers/toast-provider.mock'; import { CollectionsQuerySyncService } from '../../services'; import { CollectionsMainContentComponent } from '../collections-main-content/collections-main-content.component'; import { CollectionsDiscoverComponent } from './collections-discover.component'; -describe('CollectionsDiscoverComponent', () => { - let component: CollectionsDiscoverComponent; - let fixture: ComponentFixture; - let toastServiceMock: ToastServiceMockType; - let mockCustomDialogService: ReturnType; - let mockRoute: ReturnType; - - beforeEach(() => { - toastServiceMock = ToastServiceMock.simple(); - mockCustomDialogService = CustomDialogServiceMockBuilder.create().build(); - mockRoute = ActivatedRouteMockBuilder.create().withParams({ providerId: 'provider-1' }).build(); - - TestBed.configureTestingModule({ - imports: [ - CollectionsDiscoverComponent, - ...MockComponents(SearchInputComponent, CollectionsMainContentComponent, LoadingSpinnerComponent), - ], - providers: [ - provideOSFCore(), - MockProvider(ToastService, toastServiceMock), - MockProvider(CustomDialogService, mockCustomDialogService), - MockProvider(ActivatedRoute, mockRoute), - provideMockStore({ - signals: [ - { selector: CollectionsSelectors.getCollectionProvider, value: MOCK_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 MOCK_COLLECTION_IRI = 'http://localhost:8000/v2/collections/collection-1/'; + +const MOCK_COLLECTION_PROVIDER = { + ...MOCK_PROVIDER, + iri: MOCK_COLLECTION_IRI, + primaryCollection: { id: 'collection-1', type: 'collections' }, + requiredMetadataTemplate: null, +}; + +const MOCK_COLLECTION_DETAILS = { + id: 'collection-1', + type: 'collections', + iri: MOCK_COLLECTION_IRI, + title: 'Test Collection', + dateCreated: '2024-01-01', + dateModified: '2024-01-01', + bookmarks: false, + isPromoted: false, + isPublic: true, + filters: { + collectedType: [], + disease: [], + dataType: [], + gradeLevels: [], + issue: [], + programArea: [], + schoolType: [], + status: [], + studyDesign: [], + volume: [], + }, +}; + +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: { + '@context': { + properties: { + field1: { enum: ['https://schema.metadatacenter.org/properties/test-field-uuid'] }, + }, + }, + field1: { + '@type': 'https://schema.metadatacenter.org/core/TemplateField', + _valueConstraints: { + literals: [{ label: 'Option A' }, { label: 'Option B' }], + }, + }, + }, + _ui: { + order: ['field1'], + propertyLabels: { field1: 'Field One' }, + propertyDescriptions: {}, + }, }, - }); + }, + }, +}; - fixture = TestBed.createComponent(CollectionsDiscoverComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); +interface SetupOptions { + collectionSubmissionWithCedar?: boolean; + provider?: typeof MOCK_COLLECTION_PROVIDER | typeof MOCK_COLLECTION_PROVIDER_WITH_TEMPLATE; +} - it('should create', () => { - expect(component).toBeTruthy(); - }); +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(); - it('should initialize with default values', () => { - expect(component.providerId()).toBe('provider-1'); - expect(component.searchControl.value).toBe(''); + 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: collectionSubmissionWithCedar ? MOCK_COLLECTION_DETAILS : null, + }, + { selector: CollectionsSelectors.getAllSelectedFilters, value: {} }, + { selector: CollectionsSelectors.getSortBy, value: 'date' }, + { selector: CollectionsSelectors.getSearchText, value: '' }, + { selector: CollectionsSelectors.getPageNumber, value: '1' }, + { selector: CollectionsSelectors.getCollectionProviderLoading, value: false }, + { + selector: UserSelectors.getActiveFlags, + value: collectionSubmissionWithCedar ? ['collection_submission_with_cedar'] : [], + }, + ], + }), + ], + }).overrideComponent(CollectionsDiscoverComponent, { + set: { + providers: [MockProvider(CollectionsQuerySyncService)], + }, }); - it('should handle search triggered', () => { - const searchValue = 'test search'; + const fixture = TestBed.createComponent(CollectionsDiscoverComponent); + const component = fixture.componentInstance; + const store = TestBed.inject(Store); + fixture.detectChanges(); - component.onSearchTriggered(searchValue); + return { fixture, component, store }; +} - expect(component).toBeTruthy(); - }); +describe('CollectionsDiscoverComponent', () => { + describe('legacy mode (collectionSubmissionWithCedar = false)', () => { + let component: CollectionsDiscoverComponent; + let fixture: ComponentFixture; - it('should have provider id signal', () => { - expect(component.providerId()).toBe('provider-1'); - }); + beforeEach(() => { + ({ fixture, component } = setup()); + }); - it('should have collection provider data', () => { - expect(component.collectionProvider()).toEqual(MOCK_PROVIDER); - }); + it('should create', () => { + expect(component).toBeTruthy(); + }); - it('should have collection details', () => { - expect(component.collectionDetails()).toBeNull(); - }); + it('should set useShareTroveSearch to false', () => { + expect(component.useShareTroveSearch()).toBe(false); + }); - it('should have selected filters', () => { - expect(component.selectedFilters()).toEqual({}); - }); + it('should initialize with default values', () => { + expect(component.providerId()).toBe('provider-1'); + expect(component.searchControl.value).toBe(''); + }); - it('should have sort by value', () => { - expect(component.sortBy()).toBe('date'); - }); + it('should have collection provider data', () => { + expect(component.collectionProvider()).toEqual(MOCK_COLLECTION_PROVIDER); + }); - it('should have search text', () => { - expect(component.searchText()).toBe(''); - }); + it('should have collection details as null', () => { + expect(component.collectionDetails()).toBeNull(); + }); - it('should have page number', () => { - expect(component.pageNumber()).toBe('1'); - }); + it('should have selected filters', () => { + expect(component.selectedFilters()).toEqual({}); + }); - it('should have loading state', () => { - expect(component.isProviderLoading()).toBe(false); - }); + it('should have sort by value', () => { + expect(component.sortBy()).toBe('date'); + }); + + it('should have search text', () => { + expect(component.searchText()).toBe(''); + }); + + it('should have page number', () => { + expect(component.pageNumber()).toBe('1'); + }); - it('should compute primary collection id', () => { - expect(component.primaryCollectionId()).toBe(MOCK_PROVIDER.primaryCollection?.id); + it('should have loading state', () => { + expect(component.isProviderLoading()).toBe(false); + }); + + it('should compute primary collection id', () => { + expect(component.primaryCollectionId()).toBe('collection-1'); + }); + + it('should handle search control value changes', () => { + component.searchControl.setValue('new search value'); + expect(component.searchControl.value).toBe('new search value'); + }); + + it('should not initialize default search filters', () => { + expect(component.defaultSearchFiltersInitialized()).toBe(false); + }); + + 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 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 handle search control value changes', () => { - const searchValue = 'new search value'; + describe('shtrove mode (collectionSubmissionWithCedar = true)', () => { + it('should set useShareTroveSearch to true', () => { + const { component } = setup({ collectionSubmissionWithCedar: true }); + expect(component.useShareTroveSearch()).toBe(true); + }); + + it('should initialize default search filters', () => { + const { component } = setup({ collectionSubmissionWithCedar: true }); + expect(component.defaultSearchFiltersInitialized()).toBe(true); + }); - component.searchControl.setValue(searchValue); + 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(component.searchControl.value).toBe(searchValue); + expect(setDefaultFilter).toBeDefined(); + expect(setDefaultFilter.filterKey).toBe('isPartOfCollection'); + expect(setDefaultFilter.value).toBe(MOCK_COLLECTION_IRI); + }); + + it('should not dispatch SetExtraFilters when provider has no requiredMetadataTemplate', () => { + const { store } = setup({ collectionSubmissionWithCedar: true }); + const dispatched = (store.dispatch as Mock).mock.calls.flat(); + + expect(dispatched.some((c: unknown) => c instanceof SetExtraFilters)).toBe(false); + }); + + it('should dispatch SetExtraFilters when provider has a requiredMetadataTemplate', () => { + const { store } = setup({ + collectionSubmissionWithCedar: true, + provider: MOCK_COLLECTION_PROVIDER_WITH_TEMPLATE, + }); + + 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'); + expect(setExtraFilters.filters[0].cedarPropertyIri).toBe('test-field-uuid'); + expect(setExtraFilters.filters[0].options).toHaveLength(2); + }); + + 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(); + }); }); }); 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 0c43f26cb..62c60061d 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 @@ -21,8 +21,11 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { UserSelectors } from '@core/store/user'; +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 { CedarTemplateFilterMapper } from '@osf/shared/mappers/filters/cedar-template-filter.mapper'; import { CollectionsFilters } from '@osf/shared/models/collections/collections-filters.model'; import { BrandService } from '@osf/shared/services/brand.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; @@ -37,6 +40,8 @@ import { SetPageNumber, SetSearchValue, } from '@osf/shared/stores/collections'; +import { ResetSearchState, SetDefaultFilterValue, SetExtraFilters } from '@osf/shared/stores/global-search'; +import { COLLECTION_SUBMISSION_WITH_CEDAR } from '@shared/constants/feature-flags.const'; import { CollectionsQuerySyncService } from '../../services'; import { CollectionsHelpDialogComponent } from '../collections-help-dialog/collections-help-dialog.component'; @@ -49,6 +54,7 @@ import { CollectionsMainContentComponent } from '../collections-main-content/col RouterLink, SearchInputComponent, CollectionsMainContentComponent, + GlobalSearchComponent, LoadingSpinnerComponent, TranslatePipe, ], @@ -70,6 +76,10 @@ export class CollectionsDiscoverComponent { searchControl = new FormControl(''); providerId = signal(''); + defaultSearchFiltersInitialized = signal(false); + + activeFlags = select(UserSelectors.getActiveFlags); + readonly useShareTroveSearch = computed(() => this.activeFlags().includes(COLLECTION_SUBMISSION_WITH_CEDAR)); collectionProvider = select(CollectionsSelectors.getCollectionProvider); collectionDetails = select(CollectionsSelectors.getCollectionDetails); @@ -89,12 +99,30 @@ export class CollectionsDiscoverComponent { setPageNumber: SetPageNumber, clearCollections: ClearCollections, clearCollectionsSubmissions: ClearCollectionSubmissions, + setDefaultFilterValue: SetDefaultFilterValue, + setExtraFilters: SetExtraFilters, + resetSearchState: ResetSearchState, }); constructor() { this.initializeProvider(); - this.setupEffects(); + this.setupBrandingEffect(); + this.setupShareTroveSearchEffect(); + this.setupCollectionDetailsEffect(); + this.setupUrlSyncEffect(); + this.setupLegacySearchEffect(); this.setupSearchBinding(); + + this.destroyRef.onDestroy(() => { + if (this.isBrowser) { + this.actions.clearCollections(); + if (this.useShareTroveSearch()) { + this.actions.resetSearchState(); + } + this.headerStyleHelper.resetToDefaults(); + this.brandService.resetBranding(); + } + }); } openHelpDialog(): void { @@ -102,8 +130,10 @@ export class CollectionsDiscoverComponent { } onSearchTriggered(searchValue: string): void { - this.actions.setSearchValue(searchValue); - this.actions.setPageNumber('1'); + if (!this.useShareTroveSearch()) { + this.actions.setSearchValue(searchValue); + this.actions.setPageNumber('1'); + } } private initializeProvider(): void { @@ -117,26 +147,50 @@ export class CollectionsDiscoverComponent { this.actions.getCollectionProvider(id); } - private setupEffects(): void { - this.querySyncService.initializeFromUrl(); - + private setupBrandingEffect(): void { effect(() => { - const collectionId = this.primaryCollectionId(); - if (collectionId) { - this.actions.getCollectionDetails(collectionId); + const provider = this.collectionProvider(); + + if (provider?.brand) { + this.brandService.applyBranding(provider.brand); + this.headerStyleHelper.applyHeaderStyles(provider.brand.secondaryColor, provider.brand.backgroundColor || ''); } }); + } + private setupShareTroveSearchEffect(): void { effect(() => { const provider = this.collectionProvider(); + const collectionIri = provider?.iri; + if (!this.useShareTroveSearch() || !provider || !collectionIri || this.defaultSearchFiltersInitialized()) return; - if (provider && provider.brand) { - this.brandService.applyBranding(provider.brand); - this.headerStyleHelper.applyHeaderStyles(provider.brand.secondaryColor, provider.brand.backgroundColor || ''); + this.actions.setDefaultFilterValue('isPartOfCollection', collectionIri); + + if (provider.requiredMetadataTemplate?.attributes?.template) { + const extraFilters = CedarTemplateFilterMapper.fromTemplate( + provider.requiredMetadataTemplate.attributes.template + ); + this.actions.setExtraFilters(extraFilters); } + + this.defaultSearchFiltersInitialized.set(true); }); + } + + private setupCollectionDetailsEffect(): void { + effect(() => { + const collectionId = this.primaryCollectionId(); + if (collectionId) { + this.actions.getCollectionDetails(collectionId); + } + }); + } + private setupUrlSyncEffect(): void { effect(() => { + if (this.useShareTroveSearch()) return; + this.querySyncService.initializeFromUrl(); + const searchText = this.searchText(); const sortBy = this.sortBy(); const selectedFilters = this.selectedFilters(); @@ -146,8 +200,12 @@ export class CollectionsDiscoverComponent { this.querySyncService.syncStoreToUrl(searchText, sortBy, selectedFilters, pageNumber); } }); + } + private setupLegacySearchEffect(): void { effect(() => { + if (this.useShareTroveSearch()) return; + const searchText = this.searchText(); const sortBy = this.sortBy(); const selectedFilters = this.selectedFilters(); @@ -161,19 +219,11 @@ export class CollectionsDiscoverComponent { this.actions.searchCollectionSubmissions(providerId, searchText, activeFilters, pageNumber, sortBy); } }); - - this.destroyRef.onDestroy(() => { - if (this.isBrowser) { - this.actions.clearCollections(); - this.headerStyleHelper.resetToDefaults(); - this.brandService.resetBranding(); - } - }); } private getActiveFilters(filters: CollectionsFilters): Record { return Object.entries(filters) - .filter(([_, value]) => value.length) + .filter(([, value]) => value.length) .reduce( (acc, [key, value]) => { acc[key] = value; diff --git a/src/app/features/collections/services/project-metadata-form.service.ts b/src/app/features/collections/services/project-metadata-form.service.ts index 84a563259..a2a19527a 100644 --- a/src/app/features/collections/services/project-metadata-form.service.ts +++ b/src/app/features/collections/services/project-metadata-form.service.ts @@ -45,10 +45,10 @@ export class ProjectMetadataFormService { const yearControl = form.get(ProjectMetadataFormControls.LicenseYear); const copyrightHoldersControl = form.get(ProjectMetadataFormControls.CopyrightHolders); - const validators = license.requiredFields.length ? [CustomValidators.requiredTrimmed()] : []; - - yearControl?.setValidators(validators); - copyrightHoldersControl?.setValidators(validators); + yearControl?.setValidators(license.requiredFields.includes('year') ? [CustomValidators.requiredTrimmed()] : []); + copyrightHoldersControl?.setValidators( + license.requiredFields.includes('copyrightHolders') ? [CustomValidators.requiredTrimmed()] : [] + ); yearControl?.updateValueAndValidity(); copyrightHoldersControl?.updateValueAndValidity(); 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/metadata-collection-item/metadata-collection-item.component.html b/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.html index cf05872c9..d7a384a20 100644 --- a/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.html +++ b/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.html @@ -22,6 +22,16 @@ +@if (showCedarViewer()) { +
+ +
+} + @if (showAttributes()) {
@for (attribute of attributes(); track attribute.key) { diff --git a/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.spec.ts b/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.spec.ts index 106fea114..1ed5237ae 100644 --- a/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.spec.ts +++ b/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.spec.ts @@ -4,32 +4,23 @@ import { provideRouter } from '@angular/router'; import { CollectionSubmissionReviewState } from '@osf/shared/enums/collection-submission-review-state.enum'; import { CollectionSubmission } from '@osf/shared/models/collections/collections.model'; +import { + MOCK_CEDAR_RECORD, + MOCK_CEDAR_SUBMISSION, + MOCK_CEDAR_TEMPLATE, +} from '@testing/data/collections/cedar-metadata.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { MetadataCollectionItemComponent } from './metadata-collection-item.component'; +const mockSubmission: CollectionSubmission = MOCK_CEDAR_SUBMISSION; +const mockCedarTemplate = MOCK_CEDAR_TEMPLATE; +const mockCedarRecord = MOCK_CEDAR_RECORD; + describe('MetadataCollectionItemComponent', () => { let component: MetadataCollectionItemComponent; let fixture: ComponentFixture; - const mockSubmission: CollectionSubmission = { - id: '1', - type: 'collection-submission', - collectionTitle: 'Test Collection', - collectionId: 'collection-123', - reviewsState: CollectionSubmissionReviewState.Pending, - collectedType: 'preprint', - status: 'pending', - volume: '1', - issue: '1', - programArea: 'Science', - schoolType: 'University', - studyDesign: 'Experimental', - dataType: 'Quantitative', - disease: 'Cancer', - gradeLevels: 'Graduate', - }; - beforeEach(() => { TestBed.configureTestingModule({ imports: [MetadataCollectionItemComponent], @@ -149,4 +140,76 @@ describe('MetadataCollectionItemComponent', () => { const attributesSection = fixture.nativeElement.querySelector('.flex.flex-column.gap-2.mt-2'); expect(attributesSection).toBeFalsy(); }); + + describe('CEDAR mode', () => { + it('should not show cedar viewer when isCedarMode is false', () => { + fixture.componentRef.setInput('isCedarMode', false); + fixture.componentRef.setInput('cedarRecord', mockCedarRecord); + fixture.componentRef.setInput('cedarTemplate', mockCedarTemplate); + fixture.detectChanges(); + + expect(component.showCedarViewer()).toBe(false); + }); + + it('should not show cedar viewer when cedarRecord is null', () => { + fixture.componentRef.setInput('isCedarMode', true); + fixture.componentRef.setInput('cedarRecord', null); + fixture.componentRef.setInput('cedarTemplate', mockCedarTemplate); + fixture.detectChanges(); + + expect(component.showCedarViewer()).toBe(false); + }); + + it('should not show cedar viewer when cedarTemplate is null', () => { + fixture.componentRef.setInput('isCedarMode', true); + fixture.componentRef.setInput('cedarRecord', mockCedarRecord); + fixture.componentRef.setInput('cedarTemplate', null); + fixture.detectChanges(); + + expect(component.showCedarViewer()).toBe(false); + }); + + it('should show cedar viewer when isCedarMode, record, and template are provided', () => { + fixture.componentRef.setInput('isCedarMode', true); + fixture.componentRef.setInput('cedarRecord', mockCedarRecord); + fixture.componentRef.setInput('cedarTemplate', mockCedarTemplate); + fixture.detectChanges(); + + expect(component.showCedarViewer()).toBe(true); + }); + + it('should not show cedar viewer when submission is removed', () => { + fixture.componentRef.setInput('isCedarMode', true); + fixture.componentRef.setInput('cedarRecord', mockCedarRecord); + fixture.componentRef.setInput('cedarTemplate', mockCedarTemplate); + fixture.componentRef.setInput('submission', { + ...mockSubmission, + reviewsState: CollectionSubmissionReviewState.Removed, + }); + fixture.detectChanges(); + + expect(component.showCedarViewer()).toBe(false); + }); + + it('should not show attributes in cedar mode', () => { + fixture.componentRef.setInput('isCedarMode', true); + fixture.detectChanges(); + + expect(component.showAttributes()).toBe(false); + }); + + it('should compute cedarMetadata from record', () => { + fixture.componentRef.setInput('cedarRecord', mockCedarRecord); + fixture.detectChanges(); + + expect(component.cedarMetadata()).toEqual({ field: 'value' }); + }); + + it('should return empty object for cedarMetadata when no record', () => { + fixture.componentRef.setInput('cedarRecord', null); + fixture.detectChanges(); + + expect(component.cedarMetadata()).toEqual({}); + }); + }); }); diff --git a/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.ts b/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.ts index 1c023afd9..c9d700248 100644 --- a/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.ts +++ b/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.ts @@ -3,10 +3,19 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Tag } from 'primeng/tag'; -import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + computed, + CUSTOM_ELEMENTS_SCHEMA, + input, + ViewEncapsulation, +} from '@angular/core'; import { RouterLink } from '@angular/router'; import { collectionFilterNames } from '@osf/features/collections/constants'; +import { CEDAR_VIEWER_CONFIG } from '@osf/features/metadata/constants'; +import { CedarMetadataDataTemplateJsonApi, CedarMetadataRecordData } from '@osf/features/metadata/models'; import { CollectionSubmissionReviewState } from '@osf/shared/enums/collection-submission-review-state.enum'; import { CollectionSubmission } from '@osf/shared/models/collections/collections.model'; import { KeyValueModel } from '@osf/shared/models/common/key-value.model'; @@ -18,11 +27,18 @@ import { CollectionStatusSeverityPipe } from '@osf/shared/pipes/collection-statu templateUrl: './metadata-collection-item.component.html', styleUrl: './metadata-collection-item.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + encapsulation: ViewEncapsulation.None, }) export class MetadataCollectionItemComponent { readonly CollectionSubmissionReviewState = CollectionSubmissionReviewState; submission = input.required(); + isCedarMode = input(false); + cedarRecord = input(null); + cedarTemplate = input(null); + + cedarViewerConfig = CEDAR_VIEWER_CONFIG; showSubmissionButton = computed(() => this.submission().reviewsState === CollectionSubmissionReviewState.Accepted); @@ -32,9 +48,25 @@ export class MetadataCollectionItemComponent { }); showAttributes = computed( - () => this.submission().reviewsState !== CollectionSubmissionReviewState.Removed && !!this.attributes().length + () => + !this.isCedarMode() && + this.submission().reviewsState !== CollectionSubmissionReviewState.Removed && + !!this.attributes().length + ); + + showCedarViewer = computed( + () => + this.isCedarMode() && + !!this.cedarRecord() && + !!this.cedarTemplate()?.attributes?.template && + this.submission().reviewsState !== CollectionSubmissionReviewState.Removed ); + cedarMetadata = computed(() => { + const record = this.cedarRecord(); + return record?.attributes?.metadata ? (record.attributes.metadata as Record) : {}; + }); + attributes = computed(() => { const submission = this.submission(); const attributes: KeyValueModel[] = []; diff --git a/src/app/features/metadata/components/metadata-collections/metadata-collections.component.html b/src/app/features/metadata/components/metadata-collections/metadata-collections.component.html index 2a135bdee..d9d0a0815 100644 --- a/src/app/features/metadata/components/metadata-collections/metadata-collections.component.html +++ b/src/app/features/metadata/components/metadata-collections/metadata-collections.component.html @@ -9,7 +9,12 @@

{{ 'project.overview.metadata.collection' | translate }}

@if (submissions?.length) { @for (submission of submissions; track submission.id) { - + } } @else {

{{ 'project.overview.metadata.noCollections' | translate }}

diff --git a/src/app/features/metadata/components/metadata-collections/metadata-collections.component.spec.ts b/src/app/features/metadata/components/metadata-collections/metadata-collections.component.spec.ts index bd9568d2c..9d446b991 100644 --- a/src/app/features/metadata/components/metadata-collections/metadata-collections.component.spec.ts +++ b/src/app/features/metadata/components/metadata-collections/metadata-collections.component.spec.ts @@ -4,12 +4,22 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { ActivatedRoute } from '@angular/router'; +import { + MOCK_CEDAR_RECORD, + MOCK_CEDAR_SUBMISSION, + MOCK_CEDAR_TEMPLATE, +} from '@testing/data/collections/cedar-metadata.mock'; import { MOCK_PROJECT_COLLECTION_SUBMISSIONS } from '@testing/data/collections/collection-submissions.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; import { MetadataCollectionsComponent } from './metadata-collections.component'; +const mockTemplateId = MOCK_CEDAR_TEMPLATE.id; +const mockCedarTemplate = MOCK_CEDAR_TEMPLATE; +const mockCedarRecord = MOCK_CEDAR_RECORD; +const mockSubmissionsWithTemplate = [MOCK_CEDAR_SUBMISSION]; + describe('MetadataCollectionsComponent', () => { let component: MetadataCollectionsComponent; let fixture: ComponentFixture; @@ -53,4 +63,49 @@ describe('MetadataCollectionsComponent', () => { const content = fixture.nativeElement.textContent; expect(content).toContain('project.overview.metadata.noCollections'); }); + + it('should default isCedarMode to false', () => { + expect(component.isCedarMode()).toBe(false); + }); + + it('should build cedarRecordByTemplateId map from records', () => { + fixture.componentRef.setInput('cedarRecords', [mockCedarRecord]); + fixture.detectChanges(); + + const map = component.cedarRecordByTemplateId(); + expect(map.get(mockTemplateId)).toEqual(mockCedarRecord); + }); + + it('should build empty cedarRecordByTemplateId map when no records', () => { + fixture.componentRef.setInput('cedarRecords', null); + fixture.detectChanges(); + + expect(component.cedarRecordByTemplateId().size).toBe(0); + }); + + it('should build cedarTemplateById map from templates', () => { + fixture.componentRef.setInput('cedarTemplates', [mockCedarTemplate]); + fixture.detectChanges(); + + const map = component.cedarTemplateById(); + expect(map.get(mockTemplateId)).toEqual(mockCedarTemplate); + }); + + it('should build empty cedarTemplateById map when no templates', () => { + fixture.componentRef.setInput('cedarTemplates', null); + fixture.detectChanges(); + + expect(component.cedarTemplateById().size).toBe(0); + }); + + it('should pass matching cedarRecord to items in cedar mode', () => { + fixture.componentRef.setInput('isCedarMode', true); + fixture.componentRef.setInput('projectSubmissions', mockSubmissionsWithTemplate); + fixture.componentRef.setInput('cedarRecords', [mockCedarRecord]); + fixture.componentRef.setInput('cedarTemplates', [mockCedarTemplate]); + fixture.detectChanges(); + + const items = fixture.debugElement.queryAll(By.css('osf-metadata-collection-item')); + expect(items.length).toBe(1); + }); }); diff --git a/src/app/features/metadata/components/metadata-collections/metadata-collections.component.ts b/src/app/features/metadata/components/metadata-collections/metadata-collections.component.ts index affc90e98..950d7e2ac 100644 --- a/src/app/features/metadata/components/metadata-collections/metadata-collections.component.ts +++ b/src/app/features/metadata/components/metadata-collections/metadata-collections.component.ts @@ -3,8 +3,9 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Card } from 'primeng/card'; import { Skeleton } from 'primeng/skeleton'; -import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; +import { CedarMetadataDataTemplateJsonApi, CedarMetadataRecordData } from '@osf/features/metadata/models'; import { CollectionSubmission } from '@osf/shared/models/collections/collections.model'; import { MetadataCollectionItemComponent } from '../metadata-collection-item/metadata-collection-item.component'; @@ -19,4 +20,22 @@ import { MetadataCollectionItemComponent } from '../metadata-collection-item/met export class MetadataCollectionsComponent { projectSubmissions = input(null); isProjectSubmissionsLoading = input(false); + cedarRecords = input(null); + cedarTemplates = input(null); + isCedarMode = input(false); + + cedarRecordByTemplateId = computed(() => { + const records = this.cedarRecords(); + return new Map( + records?.flatMap((record) => { + const templateId = record.relationships?.template?.data?.id; + return templateId ? [[templateId, record] as const] : []; + }) ?? [] + ); + }); + + cedarTemplateById = computed(() => { + const templates = this.cedarTemplates(); + return new Map(templates?.map((t) => [t.id, t] as const) ?? []); + }); } diff --git a/src/app/features/metadata/metadata.component.html b/src/app/features/metadata/metadata.component.html index 31a31032a..f8c7d7140 100644 --- a/src/app/features/metadata/metadata.component.html +++ b/src/app/features/metadata/metadata.component.html @@ -71,6 +71,9 @@ }
diff --git a/src/app/features/metadata/metadata.component.ts b/src/app/features/metadata/metadata.component.ts index ad6f68623..46893a926 100644 --- a/src/app/features/metadata/metadata.component.ts +++ b/src/app/features/metadata/metadata.component.ts @@ -19,6 +19,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { UserSelectors } from '@core/store/user'; import { MetadataTabsComponent } from '@osf/shared/components/metadata-tabs/metadata-tabs.component'; import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component'; import { MetadataResourceEnum } from '@osf/shared/enums/metadata-resource.enum'; @@ -47,6 +48,7 @@ import { SubjectsSelectors, UpdateResourceSubjects, } from '@osf/shared/stores/subjects'; +import { COLLECTION_SUBMISSION_WITH_CEDAR } from '@shared/constants/feature-flags.const'; import { MetadataTabsModel } from '@shared/models/metadata-tabs.model'; import { SubjectModel } from '@shared/models/subject/subject.model'; @@ -128,6 +130,11 @@ export class MetadataComponent implements OnInit, OnDestroy { private readonly environment = inject(ENVIRONMENT); private readonly signpostingService = inject(SignpostingService); + private readonly activeFlags = select(UserSelectors.getActiveFlags); + readonly collectionSubmissionWithCedar = computed(() => + this.activeFlags().includes(COLLECTION_SUBMISSION_WITH_CEDAR) + ); + private resourceId = ''; tabs = signal([]); diff --git a/src/app/features/metadata/models/cedar-metadata-template.model.ts b/src/app/features/metadata/models/cedar-metadata-template.model.ts index e75886006..7b904d7ea 100644 --- a/src/app/features/metadata/models/cedar-metadata-template.model.ts +++ b/src/app/features/metadata/models/cedar-metadata-template.model.ts @@ -7,9 +7,26 @@ export interface CedarMetadataDataTemplateJsonApi { schema_name: string; cedar_id: string; template: CedarTemplate; + is_for_collections: boolean; }; } +export const CEDAR_TEMPLATE_FIELD_TYPE = 'https://schema.metadatacenter.org/core/TemplateField'; +export const CEDAR_PROPERTIES_BASE_IRI = 'https://schema.metadatacenter.org/properties/'; + +export interface CedarTemplateField { + '@type': string; + _valueConstraints?: { + literals?: { label: string }[]; + multipleChoice?: boolean; + requiredValue?: boolean; + }; +} + +export interface CedarTemplateContextSchema { + properties: Record; +} + export interface CedarTemplate { '@id': string; '@type': string; @@ -60,6 +77,7 @@ export interface CedarMetadataTemplate { schema_name: string; cedar_id: string; template: CedarTemplate; + is_for_collections: boolean; }; } diff --git a/src/app/features/metadata/pages/add-metadata/add-metadata.component.spec.ts b/src/app/features/metadata/pages/add-metadata/add-metadata.component.spec.ts index ad9b2eae5..d7cdfcf2e 100644 --- a/src/app/features/metadata/pages/add-metadata/add-metadata.component.spec.ts +++ b/src/app/features/metadata/pages/add-metadata/add-metadata.component.spec.ts @@ -78,7 +78,7 @@ describe('AddMetadataComponent', () => { MockProvider(ToastService, toastService), provideMockStore({ signals: [ - { selector: MetadataSelectors.getCedarTemplates, value: mockCedarTemplates }, + { selector: MetadataSelectors.getCedarTemplatesExcludingCollections, value: mockCedarTemplates }, { selector: MetadataSelectors.getCedarRecords, value: mockCedarRecords }, { selector: MetadataSelectors.getCedarTemplatesLoading, value: false }, { selector: MetadataSelectors.getCedarRecord, value: { data: mockRecord } }, diff --git a/src/app/features/metadata/pages/add-metadata/add-metadata.component.ts b/src/app/features/metadata/pages/add-metadata/add-metadata.component.ts index c60deb5be..20b1d67ef 100644 --- a/src/app/features/metadata/pages/add-metadata/add-metadata.component.ts +++ b/src/app/features/metadata/pages/add-metadata/add-metadata.component.ts @@ -56,7 +56,7 @@ export class AddMetadataComponent implements OnInit { selectedTemplate: CedarMetadataDataTemplateJsonApi | null = null; existingRecord: CedarMetadataRecordData | null = null; - readonly cedarTemplates = select(MetadataSelectors.getCedarTemplates); + readonly cedarTemplates = select(MetadataSelectors.getCedarTemplatesExcludingCollections); readonly cedarRecords = select(MetadataSelectors.getCedarRecords); readonly cedarTemplatesLoading = select(MetadataSelectors.getCedarTemplatesLoading); readonly cedarRecord = select(MetadataSelectors.getCedarRecord); diff --git a/src/app/features/metadata/store/metadata.selectors.ts b/src/app/features/metadata/store/metadata.selectors.ts index a79fa0a61..ba86d6cf3 100644 --- a/src/app/features/metadata/store/metadata.selectors.ts +++ b/src/app/features/metadata/store/metadata.selectors.ts @@ -46,6 +46,16 @@ export class MetadataSelectors { return state.cedarTemplates.data; } + @Selector([MetadataState]) + static getCedarTemplatesExcludingCollections(state: MetadataStateModel) { + const templates = state.cedarTemplates.data; + if (!templates) return null; + return { + ...templates, + data: templates.data.filter((t) => !t.attributes.is_for_collections), + }; + } + @Selector([MetadataState]) static getCedarTemplatesLoading(state: MetadataStateModel) { return state.cedarTemplates.isLoading; diff --git a/src/app/features/project/overview/components/overview-collections/overview-collections.component.html b/src/app/features/project/overview/components/overview-collections/overview-collections.component.html index 5a18e2d72..2904e8c9c 100644 --- a/src/app/features/project/overview/components/overview-collections/overview-collections.component.html +++ b/src/app/features/project/overview/components/overview-collections/overview-collections.component.html @@ -29,7 +29,14 @@

{{ 'project.overview.metadata.collection' | translate }}

- @let attributes = getSubmissionAttributes(submission); + @let templateId = submission.requiredMetadataTemplateId ?? ''; + @let cedarRecord = cedarRecordByTemplateId().get(templateId) ?? null; + @let cedarTemplate = cedarTemplateById().get(templateId) ?? null; + + @let attributes = + isCedarMode() && cedarRecord && cedarTemplate?.attributes?.template + ? getCedarAttributes(cedarRecord, cedarTemplate!) + : getSubmissionAttributes(submission); @if (attributes.length) {
diff --git a/src/app/features/project/overview/components/overview-collections/overview-collections.component.spec.ts b/src/app/features/project/overview/components/overview-collections/overview-collections.component.spec.ts index 020aee7b6..57e2e414b 100644 --- a/src/app/features/project/overview/components/overview-collections/overview-collections.component.spec.ts +++ b/src/app/features/project/overview/components/overview-collections/overview-collections.component.spec.ts @@ -2,8 +2,11 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; import { collectionFilterNames } from '@osf/features/collections/constants'; +import { CedarMetadataDataTemplateJsonApi } from '@osf/features/metadata/models'; import { CollectionSubmission } from '@osf/shared/models/collections/collections.model'; +import { CEDAR_METADATA_DATA_TEMPLATE_JSON_API_MOCK } from '@testing/mocks/cedar-metadata-data-template-json-api.mock'; +import { MOCK_CEDAR_METADATA_RECORD_DATA } from '@testing/mocks/cedar-metadata-record.mock'; import { MOCK_COLLECTION_SUBMISSION_EMPTY_FILTERS, MOCK_COLLECTION_SUBMISSION_SINGLE_FILTER, @@ -92,4 +95,86 @@ describe('OverviewCollectionsComponent', () => { expect(statusAttr?.value).toBe('1'); expect(typeof statusAttr?.value).toBe('string'); }); + + it('should display cedar attributes as key-value pairs when isCedarMode is true with matching record and template', async () => { + const cedarSubmission: CollectionSubmission = { + ...MOCK_COLLECTION_SUBMISSION_EMPTY_FILTERS, + requiredMetadataTemplateId: 'template-1', + }; + const cedarTemplate: CedarMetadataDataTemplateJsonApi = + CEDAR_METADATA_DATA_TEMPLATE_JSON_API_MOCK as CedarMetadataDataTemplateJsonApi; + + fixture.componentRef.setInput('projectSubmissions', [cedarSubmission]); + fixture.componentRef.setInput('isCedarMode', true); + fixture.componentRef.setInput('cedarRecords', [MOCK_CEDAR_METADATA_RECORD_DATA]); + fixture.componentRef.setInput('cedarTemplates', [cedarTemplate]); + fixture.detectChanges(); + await fixture.whenStable(); + + const paragraphs = fixture.nativeElement.querySelectorAll('p.font-normal'); + expect(paragraphs.length).toBeGreaterThan(0); + expect(fixture.nativeElement.textContent).toContain('Project Name'); + expect(fixture.nativeElement.textContent).toContain('Test Project Name'); + }); + + it('should display submission attributes when isCedarMode is false', async () => { + const cedarSubmission: CollectionSubmission = { + ...MOCK_COLLECTION_SUBMISSION_WITH_FILTERS, + requiredMetadataTemplateId: 'template-1', + }; + const cedarTemplate: CedarMetadataDataTemplateJsonApi = + CEDAR_METADATA_DATA_TEMPLATE_JSON_API_MOCK as CedarMetadataDataTemplateJsonApi; + + fixture.componentRef.setInput('projectSubmissions', [cedarSubmission]); + fixture.componentRef.setInput('isCedarMode', false); + fixture.componentRef.setInput('cedarRecords', [MOCK_CEDAR_METADATA_RECORD_DATA]); + fixture.componentRef.setInput('cedarTemplates', [cedarTemplate]); + fixture.detectChanges(); + await fixture.whenStable(); + + const attributes = component.getSubmissionAttributes(cedarSubmission); + expect(attributes.length).toBeGreaterThan(0); + const paragraphs = fixture.nativeElement.querySelectorAll('p.font-normal'); + expect(paragraphs.length).toBe(attributes.length); + }); + + it('should fall back to submission attributes when isCedarMode is true but no matching record', async () => { + const cedarSubmission: CollectionSubmission = { + ...MOCK_COLLECTION_SUBMISSION_WITH_FILTERS, + requiredMetadataTemplateId: 'non-existent-template', + }; + + fixture.componentRef.setInput('projectSubmissions', [cedarSubmission]); + fixture.componentRef.setInput('isCedarMode', true); + fixture.componentRef.setInput('cedarRecords', []); + fixture.componentRef.setInput('cedarTemplates', []); + fixture.detectChanges(); + await fixture.whenStable(); + + const attributes = component.getSubmissionAttributes(cedarSubmission); + expect(attributes.length).toBeGreaterThan(0); + const paragraphs = fixture.nativeElement.querySelectorAll('p.font-normal'); + expect(paragraphs.length).toBe(attributes.length); + }); + + it('should extract key-value pairs from a cedar record using template field order and labels', () => { + const cedarTemplate: CedarMetadataDataTemplateJsonApi = + CEDAR_METADATA_DATA_TEMPLATE_JSON_API_MOCK as CedarMetadataDataTemplateJsonApi; + + const result = component.getCedarAttributes(MOCK_CEDAR_METADATA_RECORD_DATA, cedarTemplate); + + expect(result).toContainEqual({ key: 'Project Name', label: 'Project Name', value: 'Test Project Name' }); + }); + + it('should compute empty cedarRecordByTemplateId map when cedarRecords is null', () => { + fixture.componentRef.setInput('cedarRecords', null); + fixture.detectChanges(); + expect(component.cedarRecordByTemplateId().size).toBe(0); + }); + + it('should compute empty cedarTemplateById map when cedarTemplates is null', () => { + fixture.componentRef.setInput('cedarTemplates', null); + fixture.detectChanges(); + expect(component.cedarTemplateById().size).toBe(0); + }); }); diff --git a/src/app/features/project/overview/components/overview-collections/overview-collections.component.ts b/src/app/features/project/overview/components/overview-collections/overview-collections.component.ts index 168a3530b..fdc6cbe7a 100644 --- a/src/app/features/project/overview/components/overview-collections/overview-collections.component.ts +++ b/src/app/features/project/overview/components/overview-collections/overview-collections.component.ts @@ -5,10 +5,11 @@ import { Button } from 'primeng/button'; import { Skeleton } from 'primeng/skeleton'; import { Tag } from 'primeng/tag'; -import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; import { RouterLink } from '@angular/router'; import { collectionFilterNames } from '@osf/features/collections/constants'; +import { CedarMetadataDataTemplateJsonApi, CedarMetadataRecordData } from '@osf/features/metadata/models'; import { StopPropagationDirective } from '@osf/shared/directives/stop-propagation.directive'; import { CollectionSubmission } from '@osf/shared/models/collections/collections.model'; import { KeyValueModel } from '@osf/shared/models/common/key-value.model'; @@ -36,6 +37,24 @@ import { CollectionStatusSeverityPipe } from '@osf/shared/pipes/collection-statu export class OverviewCollectionsComponent { projectSubmissions = input(null); isProjectSubmissionsLoading = input(false); + isCedarMode = input(false); + cedarRecords = input(null); + cedarTemplates = input(null); + + cedarRecordByTemplateId = computed(() => { + const records = this.cedarRecords(); + return new Map( + records?.flatMap((record) => { + const templateId = record.relationships?.template?.data?.id; + return templateId ? [[templateId, record] as const] : []; + }) ?? [] + ); + }); + + cedarTemplateById = computed(() => { + const templates = this.cedarTemplates(); + return new Map(templates?.map((t) => [t.id, t] as const) ?? []); + }); getSubmissionAttributes(submission: CollectionSubmission): KeyValueModel[] { const attributes: KeyValueModel[] = []; @@ -54,4 +73,40 @@ export class OverviewCollectionsComponent { return attributes; } + + getCedarAttributes(record: CedarMetadataRecordData, template: CedarMetadataDataTemplateJsonApi): KeyValueModel[] { + const { order, propertyLabels } = template.attributes.template._ui; + const metadata = record.attributes.metadata as Record; + const attributes: KeyValueModel[] = []; + + for (const key of order) { + const label = propertyLabels[key]; + const value = this.formatCedarValue(metadata[key]); + if (label && value) { + attributes.push({ key, label, value }); + } + } + + return attributes; + } + + private formatCedarValue(value: unknown): string { + if (value == null) return ''; + + if (Array.isArray(value)) { + return value + .map((item) => this.formatCedarValue(item)) + .filter(Boolean) + .join(', '); + } + + if (typeof value === 'object') { + const obj = value as Record; + if ('@value' in obj && obj['@value'] != null) return String(obj['@value']); + if ('rdfs:label' in obj && obj['rdfs:label'] != null) return String(obj['rdfs:label']); + return ''; + } + + return String(value); + } } diff --git a/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.html b/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.html index dcb6c35d8..ae621a247 100644 --- a/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.html +++ b/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.html @@ -129,6 +129,34 @@

{{ 'project.overview.metadata.projectDOI' | translate }}

} + @if (!isAnonymous()) { +
+

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

+ + +
+ } + + + +
+

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

+ + +
+ +
+

{{ 'shared.tags.title' | translate }}

+ + +
+ @if (!isAnonymous()) { { { selector: ContributorsSelectors.hasMoreBibliographicContributors, value: false }, { selector: CollectionsSelectors.getCurrentProjectSubmissions, value: [] }, { selector: CollectionsSelectors.getCurrentProjectSubmissionsLoading, value: false }, + { selector: UserSelectors.getActiveFlags, value: [] }, + { selector: MetadataSelectors.getCedarRecords, value: [] }, + { selector: MetadataSelectors.getCedarTemplates, value: null }, { selector: MetadataSelectors.getCustomItemMetadata, value: null }, { selector: MetadataSelectors.isCustomItemMetadataLoading, value: false }, ], @@ -129,6 +137,8 @@ describe('ProjectOverviewMetadataComponent', () => { expect(dispatchMock).toHaveBeenCalledWith(new GetProjectSubmissions('project-1')); expect(dispatchMock).toHaveBeenCalledWith(new GetCustomItemMetadata('project-1')); expect(dispatchMock).toHaveBeenCalledWith(new GetProjectLicense(MOCK_PROJECT_OVERVIEW.licenseId)); + expect(dispatchMock).toHaveBeenCalledWith(new GetCedarMetadataRecords('project-1', ResourceType.Project)); + expect(dispatchMock).toHaveBeenCalledWith(new GetCedarMetadataTemplates()); }); it('should not dispatch init actions when project is null', () => { diff --git a/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.ts b/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.ts index bb5d2571c..1bda04633 100644 --- a/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.ts +++ b/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.ts @@ -5,10 +5,16 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { DatePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, effect, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, effect, inject } from '@angular/core'; import { Router, RouterLink } from '@angular/router'; -import { GetCustomItemMetadata, MetadataSelectors } from '@osf/features/metadata/store'; +import { UserSelectors } from '@core/store/user'; +import { + GetCedarMetadataRecords, + GetCedarMetadataTemplates, + GetCustomItemMetadata, + MetadataSelectors, +} from '@osf/features/metadata/store'; import { AffiliatedInstitutionsViewComponent } from '@osf/shared/components/affiliated-institutions-view/affiliated-institutions-view.component'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; import { FundersListComponent } from '@osf/shared/components/funders-list/funders-list.component'; @@ -28,6 +34,7 @@ import { LoadMoreBibliographicContributors, } from '@osf/shared/stores/contributors'; import { FetchSelectedSubjects, SubjectsSelectors } from '@osf/shared/stores/subjects'; +import { COLLECTION_SUBMISSION_WITH_CEDAR } from '@shared/constants/feature-flags.const'; import { GetProjectIdentifiers, @@ -88,6 +95,11 @@ export class ProjectOverviewMetadataComponent { readonly hasMoreBibliographicContributors = select(ContributorsSelectors.hasMoreBibliographicContributors); readonly projectSubmissions = select(CollectionsSelectors.getCurrentProjectSubmissions); readonly isProjectSubmissionsLoading = select(CollectionsSelectors.getCurrentProjectSubmissionsLoading); + readonly activeFlags = select(UserSelectors.getActiveFlags); + readonly cedarRecords = select(MetadataSelectors.getCedarRecords); + private readonly cedarTemplatesResponse = select(MetadataSelectors.getCedarTemplates); + readonly cedarTemplates = computed(() => this.cedarTemplatesResponse()?.data ?? null); + readonly isCedarMode = computed(() => this.activeFlags().includes(COLLECTION_SUBMISSION_WITH_CEDAR)); readonly resourceType = CurrentResourceType.Projects; readonly dateFormat = 'MMM d, y, h:mm a'; @@ -103,6 +115,8 @@ export class ProjectOverviewMetadataComponent { getCustomItemMetadata: GetCustomItemMetadata, getBibliographicContributors: GetBibliographicContributors, loadMoreBibliographicContributors: LoadMoreBibliographicContributors, + getCedarRecords: GetCedarMetadataRecords, + getCedarTemplates: GetCedarMetadataTemplates, }); constructor() { @@ -117,6 +131,8 @@ export class ProjectOverviewMetadataComponent { this.actions.getSubjects(project.id, ResourceType.Project); this.actions.getProjectSubmissions(project.id); this.actions.getLicense(project.licenseId); + this.actions.getCedarRecords(project.id, ResourceType.Project); + this.actions.getCedarTemplates(); this.actions.getCustomItemMetadata(project.id); } }); diff --git a/src/app/shared/components/generic-filter/generic-filter.component.html b/src/app/shared/components/generic-filter/generic-filter.component.html index 4288d45f2..f88ac85c8 100644 --- a/src/app/shared/components/generic-filter/generic-filter.component.html +++ b/src/app/shared/components/generic-filter/generic-filter.component.html @@ -27,7 +27,12 @@ (onLazyLoad)="loadMoreItems($event)" > -

{{ item.label }} ({{ item.cardSearchResultCount }})

+

+ {{ item.label }} + @if (item.cardSearchResultCount !== null) { + ({{ item.cardSearchResultCount }}) + } +

} diff --git a/src/app/shared/components/project-selector/project-selector.component.spec.ts b/src/app/shared/components/project-selector/project-selector.component.spec.ts index cad6e5bbe..a71d9d6bd 100644 --- a/src/app/shared/components/project-selector/project-selector.component.spec.ts +++ b/src/app/shared/components/project-selector/project-selector.component.spec.ts @@ -1,10 +1,11 @@ -import { provideStore } from '@ngxs/store'; +import { provideStore, Store } from '@ngxs/store'; import { MockProvider } from 'ng-mocks'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { UserState } from '@core/store/user'; +import { ProjectModel } from '@osf/shared/models/projects/projects.model'; import { ToastService } from '@osf/shared/services/toast.service'; import { ProjectsState } from '@shared/stores/projects'; @@ -12,9 +13,13 @@ import { provideOSFCore } from '@testing/osf.testing.provider'; import { ProjectSelectorComponent } from './project-selector.component'; +const makeProject = (id: string, isPublic: boolean): ProjectModel => + ({ id, title: `Project ${id}`, isPublic }) as ProjectModel; + describe('ProjectSelectorComponent', () => { let component: ProjectSelectorComponent; let fixture: ComponentFixture; + let store: Store; beforeEach(() => { TestBed.configureTestingModule({ @@ -22,6 +27,7 @@ describe('ProjectSelectorComponent', () => { providers: [provideOSFCore(), MockProvider(ToastService), provideStore([ProjectsState, UserState])], }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(ProjectSelectorComponent); component = fixture.componentInstance; fixture.detectChanges(); @@ -51,4 +57,36 @@ describe('ProjectSelectorComponent', () => { expect(mockEvent.originalEvent.preventDefault).toHaveBeenCalled(); }); + + describe('publicOnly filtering', () => { + const publicProject = makeProject('1', true); + const privateProject = makeProject('2', false); + + const setProjects = (projects: ProjectModel[]) => { + store.reset({ + ...store.snapshot(), + projects: { projects: { data: projects, isLoading: false, error: null } }, + }); + }; + + it('should show all projects when publicOnly is false', () => { + fixture.componentRef.setInput('publicOnly', false); + setProjects([publicProject, privateProject]); + fixture.detectChanges(); + + const ids = component.projectsOptions().map((o) => o.value.id); + expect(ids).toContain('1'); + expect(ids).toContain('2'); + }); + + it('should only show public projects when publicOnly is true', () => { + fixture.componentRef.setInput('publicOnly', true); + setProjects([publicProject, privateProject]); + fixture.detectChanges(); + + const ids = component.projectsOptions().map((o) => o.value.id); + expect(ids).toContain('1'); + expect(ids).not.toContain('2'); + }); + }); }); diff --git a/src/app/shared/components/project-selector/project-selector.component.ts b/src/app/shared/components/project-selector/project-selector.component.ts index 5cd711e0f..2b7597ec8 100644 --- a/src/app/shared/components/project-selector/project-selector.component.ts +++ b/src/app/shared/components/project-selector/project-selector.component.ts @@ -44,6 +44,7 @@ export class ProjectSelectorComponent { placeholder = input('common.buttons.select'); showClear = input(true); excludeProjectIds = input([]); + publicOnly = input(false); selectedProject = model(null); projectChange = output(); @@ -105,7 +106,9 @@ export class ProjectSelectorComponent { } const excludeSet = new Set(excludeIds); - const availableProjects = projects.filter((project) => !excludeSet.has(project.id)); + const availableProjects = projects.filter( + (project) => !excludeSet.has(project.id) && (!this.publicOnly() || project.isPublic) + ); const options = availableProjects.map((project) => ({ label: project.title, @@ -132,6 +135,10 @@ export class ProjectSelectorComponent { 'filter[current_user_permissions]': 'admin', }; + if (this.publicOnly()) { + params['filter[public]'] = 'true'; + } + if (filterTitle && filterTitle.trim()) { params['filter[title]'] = filterTitle; } diff --git a/src/app/shared/components/search-filters/search-filters.component.html b/src/app/shared/components/search-filters/search-filters.component.html index 513133e26..67db13ec0 100644 --- a/src/app/shared/components/search-filters/search-filters.component.html +++ b/src/app/shared/components/search-filters/search-filters.component.html @@ -24,7 +24,7 @@ { expect(visibleFilters.length).toBe(3); }); + it('should show CEDAR filters that have options but no resultCount', () => { + const cedarFilter: DiscoverableFilter = { + key: 'School Type', + label: 'School Type', + operator: FilterOperatorOption.AnyOf, + cedarPropertyIri: 'uuid-school-type', + options: [ + { label: 'High School', value: 'High School', cardSearchResultCount: null }, + { label: 'Middle School', value: 'Middle School', cardSearchResultCount: null }, + ], + }; + + fixture.componentRef.setInput('filters', [cedarFilter]); + fixture.detectChanges(); + + expect(component.visibleFilters()).toHaveLength(1); + expect(component.visibleFilters()[0].key).toBe('School Type'); + }); + + it('should still hide a filter with resultCount 0 and no options', () => { + const zeroCountFilter: DiscoverableFilter = { + key: 'emptyFilter', + label: 'Empty', + operator: FilterOperatorOption.AnyOf, + resultCount: 0, + options: [], + }; + + fixture.componentRef.setInput('filters', [zeroCountFilter]); + fixture.detectChanges(); + + expect(component.visibleFilters()).toHaveLength(0); + }); + it('should compute splitFilters correctly', () => { fixture.componentRef.setInput('filters', mockFilters); fixture.detectChanges(); @@ -249,4 +283,18 @@ describe('SearchFiltersComponent', () => { expect(component.selectedOptionValues()).toEqual({}); }); + + it('should return specific placeholder key for known filter keys', () => { + fixture.detectChanges(); + + const filter = { key: 'subject', label: 'Subject' } as DiscoverableFilter; + expect(component.getPlaceholderKey(filter)).toBe('common.search.filterPlaceholders.subject'); + }); + + it('should return generic placeholder key for CEDAR-derived filters not in FILTER_PLACEHOLDERS', () => { + fixture.detectChanges(); + + const cedarFilter = { key: 'Collected Type Choices', label: 'Collected Type Choices' } as DiscoverableFilter; + expect(component.getPlaceholderKey(cedarFilter)).toBe('common.search.filterPlaceholders.generic'); + }); }); diff --git a/src/app/shared/components/search-filters/search-filters.component.ts b/src/app/shared/components/search-filters/search-filters.component.ts index c87370fc3..bac738417 100644 --- a/src/app/shared/components/search-filters/search-filters.component.ts +++ b/src/app/shared/components/search-filters/search-filters.component.ts @@ -75,7 +75,7 @@ export class SearchFiltersComponent { return this.filters().filter((filter) => { if (!filter || !filter.key) return false; - return Boolean((filter.resultCount && filter.resultCount > 0) || (filter.options && filter.options.length > 0)); + return filter.resultCount === undefined || filter.resultCount > 0 || (filter.options?.length ?? 0) > 0; }); }); @@ -128,6 +128,10 @@ export class SearchFiltersComponent { this.filterOptionSelected.emit({ filter, filterOption: isChecked ? [option] : [] }); } + getPlaceholderKey(filter: DiscoverableFilter): string { + return FILTER_PLACEHOLDERS[filter.key] ?? 'common.search.filterPlaceholders.generic'; + } + private scrollPanelIntoView(key: string) { of(key) .pipe(delay(this.SCROLL_DELAY_MS), takeUntilDestroyed(this.destroyRef)) diff --git a/src/app/shared/components/search-input/search-input.component.ts b/src/app/shared/components/search-input/search-input.component.ts index 809ecb62b..fea69255b 100644 --- a/src/app/shared/components/search-input/search-input.component.ts +++ b/src/app/shared/components/search-input/search-input.component.ts @@ -29,7 +29,6 @@ export class SearchInputComponent { if (!searchValue || !searchValue?.trim()?.length) { return; } - this.triggerSearch.emit(searchValue); } } diff --git a/src/app/shared/constants/feature-flags.const.ts b/src/app/shared/constants/feature-flags.const.ts new file mode 100644 index 000000000..6c2bce9db --- /dev/null +++ b/src/app/shared/constants/feature-flags.const.ts @@ -0,0 +1 @@ +export const COLLECTION_SUBMISSION_WITH_CEDAR = 'collection_submission_with_cedar'; diff --git a/src/app/shared/mappers/collections/collections.mapper.ts b/src/app/shared/mappers/collections/collections.mapper.ts index 680b34a04..165e9544f 100644 --- a/src/app/shared/mappers/collections/collections.mapper.ts +++ b/src/app/shared/mappers/collections/collections.mapper.ts @@ -50,6 +50,7 @@ export class CollectionsMapper { favicon: response.attributes.assets.favicon, } : {}, + iri: response.links?.iri, shareSource: response.attributes.share_source, sharePublishType: response.attributes.share_publish_type, permissions: response.attributes.permissions, @@ -71,6 +72,7 @@ export class CollectionsMapper { backgroundColor: response.embeds.brand.data.attributes.background_color, } : null, + requiredMetadataTemplate: response.embeds.required_metadata_template?.data ?? null, }; } @@ -78,6 +80,7 @@ export class CollectionsMapper { return { id: response.id, type: response.type, + iri: response.links?.iri, title: replaceBadEncodedChars(response.attributes.title), dateCreated: response.attributes.date_created, dateModified: response.attributes.date_modified, @@ -116,6 +119,8 @@ export class CollectionsMapper { gradeLevels: submission.attributes.grade_levels, collectionTitle: replaceBadEncodedChars(submission.embeds.collection.data.attributes.title), collectionId: submission.embeds.collection.data.relationships.provider.data.id, + requiredMetadataTemplateId: + submission.embeds.collection.data.relationships.required_metadata_template?.data?.id ?? null, }; } @@ -240,7 +245,7 @@ export class CollectionsMapper { static toCollectionSubmissionRequest(payload: CollectionSubmissionPayload): CollectionSubmissionPayloadJsonApi { const collectionId = payload.collectionId; - const collectionsMetadata = convertToSnakeCase(payload.collectionMetadata); + const collectionsMetadata = payload.collectionMetadata ? convertToSnakeCase(payload.collectionMetadata) : {}; return { data: { @@ -268,11 +273,15 @@ export class CollectionsMapper { } static collectionSubmissionUpdateRequest(payload: CollectionSubmissionPayload) { + const collectionsMetadata = payload.collectionMetadata ? convertToSnakeCase(payload.collectionMetadata) : {}; + return { data: { id: `${payload.projectId}-${payload.collectionId}`, type: 'collection-submissions', - attributes: {}, + attributes: { + ...collectionsMetadata, + }, relationships: {}, }, }; diff --git a/src/app/shared/mappers/filters/cedar-template-filter.mapper.spec.ts b/src/app/shared/mappers/filters/cedar-template-filter.mapper.spec.ts new file mode 100644 index 000000000..b37663180 --- /dev/null +++ b/src/app/shared/mappers/filters/cedar-template-filter.mapper.spec.ts @@ -0,0 +1,144 @@ +import { CEDAR_TEMPLATE_FIELD_TYPE, CedarTemplate } from '@osf/features/metadata/models'; +import { FilterOperatorOption } from '@osf/shared/models/search/discoverable-filter.model'; + +import { CedarTemplateFilterMapper } from './cedar-template-filter.mapper'; + +const CEDAR_BASE = 'https://schema.metadatacenter.org/properties/'; + +function makeTemplate(overrides: Partial = {}): CedarTemplate { + return { + '@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: { + '@context': { + properties: { + 'School Type': { enum: [`${CEDAR_BASE}uuid-school-type`] }, + 'Study Design': { enum: [`${CEDAR_BASE}uuid-study-design`] }, + About: { enum: [`${CEDAR_BASE}uuid-about`] }, + }, + }, + 'School Type': { + '@type': CEDAR_TEMPLATE_FIELD_TYPE, + _valueConstraints: { + literals: [{ label: 'High School' }, { label: 'Middle School' }], + }, + }, + 'Study Design': { + '@type': CEDAR_TEMPLATE_FIELD_TYPE, + _valueConstraints: { + literals: [{ label: 'Intervention' }, { label: 'Correlational' }], + }, + }, + About: { + '@type': 'https://schema.metadatacenter.org/core/StaticTemplateField', + _ui: { inputType: 'richtext' }, + }, + }, + _ui: { + order: ['School Type', 'Study Design', 'About'], + propertyLabels: { 'School Type': 'School Type', 'Study Design': 'Study Design', About: 'About' }, + propertyDescriptions: {}, + }, + ...overrides, + }; +} + +describe('CedarTemplateFilterMapper', () => { + describe('fromTemplate', () => { + it('should only include TemplateField entries with literals', () => { + const filters = CedarTemplateFilterMapper.fromTemplate(makeTemplate()); + + expect(filters).toHaveLength(2); + expect(filters.map((f) => f.key)).toEqual(['School Type', 'Study Design']); + }); + + it('should skip StaticTemplateField entries', () => { + const filters = CedarTemplateFilterMapper.fromTemplate(makeTemplate()); + + expect(filters.some((f) => f.key === 'About')).toBe(false); + }); + + it('should pre-populate options from _valueConstraints.literals', () => { + const filters = CedarTemplateFilterMapper.fromTemplate(makeTemplate()); + const schoolType = filters.find((f) => f.key === 'School Type')!; + + expect(schoolType.options).toHaveLength(2); + expect(schoolType.options![0]).toEqual({ + label: 'High School', + value: 'High School', + cardSearchResultCount: null, + }); + expect(schoolType.options![1]).toEqual({ + label: 'Middle School', + value: 'Middle School', + cardSearchResultCount: null, + }); + }); + + it('should set cardSearchResultCount to null for all options', () => { + const filters = CedarTemplateFilterMapper.fromTemplate(makeTemplate()); + + filters.forEach((f) => { + f.options?.forEach((opt) => { + expect(opt.cardSearchResultCount).toBeNull(); + }); + }); + }); + + it('should set cedarPropertyIri to the UUID from the context IRI', () => { + const filters = CedarTemplateFilterMapper.fromTemplate(makeTemplate()); + const schoolType = filters.find((f) => f.key === 'School Type')!; + const studyDesign = filters.find((f) => f.key === 'Study Design')!; + + expect(schoolType.cedarPropertyIri).toBe('uuid-school-type'); + expect(studyDesign.cedarPropertyIri).toBe('uuid-study-design'); + }); + + it('should set operator to AnyOf', () => { + const filters = CedarTemplateFilterMapper.fromTemplate(makeTemplate()); + + filters.forEach((f) => { + expect(f.operator).toBe(FilterOperatorOption.AnyOf); + }); + }); + + it('should use propertyLabels for the filter label', () => { + const filters = CedarTemplateFilterMapper.fromTemplate(makeTemplate()); + + expect(filters[0].label).toBe('School Type'); + expect(filters[1].label).toBe('Study Design'); + }); + + it('should skip fields with no literals', () => { + const template = makeTemplate(); + (template.properties['School Type'] as any)._valueConstraints = { literals: [] }; + + const filters = CedarTemplateFilterMapper.fromTemplate(template); + + expect(filters.some((f) => f.key === 'School Type')).toBe(false); + }); + + it('should skip fields with an empty label', () => { + const template = makeTemplate(); + template._ui.propertyLabels['School Type'] = ' '; + + const filters = CedarTemplateFilterMapper.fromTemplate(template); + + expect(filters.some((f) => f.key === 'School Type')).toBe(false); + }); + + it('should return an empty array when no filterable fields exist', () => { + const template = makeTemplate({ + _ui: { order: ['About'], propertyLabels: { About: 'About' }, propertyDescriptions: {} }, + }); + + expect(CedarTemplateFilterMapper.fromTemplate(template)).toEqual([]); + }); + }); +}); diff --git a/src/app/shared/mappers/filters/cedar-template-filter.mapper.ts b/src/app/shared/mappers/filters/cedar-template-filter.mapper.ts new file mode 100644 index 000000000..de0d473f3 --- /dev/null +++ b/src/app/shared/mappers/filters/cedar-template-filter.mapper.ts @@ -0,0 +1,47 @@ +import { + CEDAR_PROPERTIES_BASE_IRI, + CEDAR_TEMPLATE_FIELD_TYPE, + CedarTemplate, + CedarTemplateContextSchema, + CedarTemplateField, +} from '@osf/features/metadata/models'; +import { + DiscoverableFilter, + FilterOperatorOption, + FilterOption, +} from '@osf/shared/models/search/discoverable-filter.model'; + +export class CedarTemplateFilterMapper { + static fromTemplate(template: CedarTemplate): DiscoverableFilter[] { + const { order, propertyLabels } = template._ui; + const contextProperties = (template.properties['@context'] as CedarTemplateContextSchema)?.properties ?? {}; + + return order + .filter((key) => { + const field = template.properties[key] as CedarTemplateField | undefined; + return ( + propertyLabels[key]?.trim() && + field?.['@type'] === CEDAR_TEMPLATE_FIELD_TYPE && + (field._valueConstraints?.literals?.length ?? 0) > 0 + ); + }) + .map((key) => { + const field = template.properties[key] as CedarTemplateField; + const iri = contextProperties[key]?.enum?.[0]; + const cedarPropertyIri = iri?.replace(CEDAR_PROPERTIES_BASE_IRI, ''); + const options: FilterOption[] = (field._valueConstraints!.literals ?? []).map((literal) => ({ + label: literal.label, + value: literal.label, + cardSearchResultCount: null, + })); + + return { + key, + label: propertyLabels[key], + operator: FilterOperatorOption.AnyOf, + options, + cedarPropertyIri, + }; + }); + } +} diff --git a/src/app/shared/models/collections/collection-submission-payload.model.ts b/src/app/shared/models/collections/collection-submission-payload.model.ts index 080bc0992..4ffec1fea 100644 --- a/src/app/shared/models/collections/collection-submission-payload.model.ts +++ b/src/app/shared/models/collections/collection-submission-payload.model.ts @@ -2,5 +2,5 @@ export interface CollectionSubmissionPayload { collectionId: string; projectId: string; userId: string; - collectionMetadata: Record; + collectionMetadata?: Record; } 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 2ce9402af..885295386 100644 --- a/src/app/shared/models/collections/collections-json-api.model.ts +++ b/src/app/shared/models/collections/collections-json-api.model.ts @@ -1,3 +1,4 @@ +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'; @@ -9,11 +10,17 @@ import { UserDataErrorResponseJsonApi } from '../user/user-json-api.model'; export interface CollectionProviderResponseJsonApi { id: string; type: string; + links?: { + iri?: string; + }; attributes: CollectionsProviderAttributesJsonApi; embeds: { brand: { data?: BrandDataJsonApi; }; + required_metadata_template?: { + data?: CedarMetadataDataTemplateJsonApi | null; + }; }; relationships: { primary_collection: { @@ -28,6 +35,9 @@ export interface CollectionProviderResponseJsonApi { export interface CollectionDetailsResponseJsonApi { id: string; type: string; + links?: { + iri?: string; + }; attributes: { title: string; date_created: string; @@ -76,6 +86,12 @@ export interface CollectionSubmissionJsonApi { id: 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 6b67d7d16..e2b6bdf0b 100644 --- a/src/app/shared/models/collections/collections.model.ts +++ b/src/app/shared/models/collections/collections.model.ts @@ -1,3 +1,4 @@ +import { CedarMetadataDataTemplateJsonApi } from '@osf/features/metadata/models'; import { CollectionSubmissionReviewAction } from '@osf/features/moderation/models'; import { CollectionSubmissionReviewState } from '@osf/shared/enums/collection-submission-review-state.enum'; @@ -7,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; @@ -19,6 +21,7 @@ export interface CollectionProvider extends BaseProviderModel { }; brand: BrandModel | null; defaultLicenseId?: string | null; + requiredMetadataTemplate?: CedarMetadataDataTemplateJsonApi | null; } export interface CollectionFilters { @@ -37,6 +40,7 @@ export interface CollectionFilters { export interface CollectionDetails { id: string; type: string; + iri?: string; title: string; dateCreated: string; dateModified: string; @@ -62,6 +66,7 @@ export interface CollectionSubmission { dataType: string; disease: string; gradeLevels: string; + requiredMetadataTemplateId?: string | null; } export interface CollectionSubmissionWithGuid { diff --git a/src/app/shared/models/environment.model.ts b/src/app/shared/models/environment.model.ts index 184fe4ce4..3a688ca4e 100644 --- a/src/app/shared/models/environment.model.ts +++ b/src/app/shared/models/environment.model.ts @@ -65,4 +65,5 @@ export interface EnvironmentModel { */ googleFilePickerAppId: number; throttleToken: string; + collectionSubmissionWithCedar: boolean; } diff --git a/src/app/shared/models/search/discoverable-filter.model.ts b/src/app/shared/models/search/discoverable-filter.model.ts index fb313ee49..005f9b1dc 100644 --- a/src/app/shared/models/search/discoverable-filter.model.ts +++ b/src/app/shared/models/search/discoverable-filter.model.ts @@ -11,6 +11,7 @@ export interface DiscoverableFilter { isLoaded?: boolean; isPaginationLoading?: boolean; isSearchLoading?: boolean; + cedarPropertyIri?: string; } export enum FilterOperatorOption { @@ -22,5 +23,5 @@ export enum FilterOperatorOption { export interface FilterOption { label: string; value: string; - cardSearchResultCount: number; + cardSearchResultCount: number | null; } diff --git a/src/app/shared/services/collections.service.ts b/src/app/shared/services/collections.service.ts index c35b0aead..80a1577e4 100644 --- a/src/app/shared/services/collections.service.ts +++ b/src/app/shared/services/collections.service.ts @@ -56,7 +56,7 @@ export class CollectionsService { private actions = createDispatchMap({ setTotalSubmissions: SetTotalSubmissions }); getCollectionProvider(collectionName: string): Observable { - const url = `${this.apiUrl}/providers/collections/${collectionName}/?embed=brand`; + const url = `${this.apiUrl}/providers/collections/${collectionName}/?embed=brand&embed=required_metadata_template`; return this.jsonApiService .get>(url) diff --git a/src/app/shared/stores/collections/collections.selectors.ts b/src/app/shared/stores/collections/collections.selectors.ts index ca076ada9..22620d7ae 100644 --- a/src/app/shared/stores/collections/collections.selectors.ts +++ b/src/app/shared/stores/collections/collections.selectors.ts @@ -21,6 +21,11 @@ export class CollectionsSelectors { return state.collectionProvider.data; } + @Selector([CollectionsState]) + static getRequiredMetadataTemplate(state: CollectionsStateModel) { + return state.collectionProvider.data?.requiredMetadataTemplate ?? null; + } + @Selector([CollectionsState]) static getCollectionDetails(state: CollectionsStateModel) { return state.collectionDetails.data; diff --git a/src/app/shared/stores/collections/collections.state.ts b/src/app/shared/stores/collections/collections.state.ts index c419afe56..55a55c089 100644 --- a/src/app/shared/stores/collections/collections.state.ts +++ b/src/app/shared/stores/collections/collections.state.ts @@ -1,6 +1,6 @@ import { Action, State, StateContext } from '@ngxs/store'; -import { catchError, forkJoin, of, switchMap, tap } from 'rxjs'; +import { catchError, forkJoin, map, of, switchMap, tap } from 'rxjs'; import { inject, Injectable } from '@angular/core'; import { Router } from '@angular/router'; @@ -116,7 +116,17 @@ export class CollectionsState { } const submissionRequests = collections.map((collection) => - this.collectionsService.fetchCurrentSubmission(action.projectId, collection.id) + this.collectionsService.fetchCurrentSubmission(action.projectId, collection.id).pipe( + switchMap((submission) => + this.collectionsService.getCollectionProvider(submission.collectionId).pipe( + map((provider) => ({ + ...submission, + requiredMetadataTemplateId: provider.requiredMetadataTemplate?.id ?? null, + })), + catchError(() => of(submission)) + ) + ) + ) ); return forkJoin(submissionRequests); diff --git a/src/app/shared/stores/global-search/global-search.actions.ts b/src/app/shared/stores/global-search/global-search.actions.ts index 597741cfd..9891b2469 100644 --- a/src/app/shared/stores/global-search/global-search.actions.ts +++ b/src/app/shared/stores/global-search/global-search.actions.ts @@ -1,5 +1,5 @@ import { StringOrNull } from '@osf/shared/helpers/types.helper'; -import { FilterOption } from '@osf/shared/models/search/discoverable-filter.model'; +import { DiscoverableFilter, FilterOption } from '@osf/shared/models/search/discoverable-filter.model'; import { ResourceType } from '@shared/enums/resource-type.enum'; export class FetchResources { @@ -81,6 +81,12 @@ export class LoadMoreFilterOptions { constructor(public filterKey: string) {} } +export class SetExtraFilters { + static readonly type = '[GlobalSearch] Set Extra Filters'; + + constructor(public filters: DiscoverableFilter[]) {} +} + export class ResetSearchState { static readonly type = '[GlobalSearch] Reset Search State'; } diff --git a/src/app/shared/stores/global-search/global-search.model.ts b/src/app/shared/stores/global-search/global-search.model.ts index 65aeaa7d8..1eee42345 100644 --- a/src/app/shared/stores/global-search/global-search.model.ts +++ b/src/app/shared/stores/global-search/global-search.model.ts @@ -7,6 +7,7 @@ import { AsyncStateModel } from '@osf/shared/models/store/async-state.model'; export interface GlobalSearchStateModel { resources: AsyncStateModel; filters: DiscoverableFilter[]; + extraFilters: DiscoverableFilter[]; defaultFilterOptions: Record; selectedFilterOptions: Record; filterOptionsCache: Record; @@ -28,6 +29,7 @@ export const GLOBAL_SEARCH_STATE_DEFAULTS = { error: null, }, filters: [], + extraFilters: [], defaultFilterOptions: {}, selectedFilterOptions: {}, filterOptionsCache: {}, diff --git a/src/app/shared/stores/global-search/global-search.state.spec.ts b/src/app/shared/stores/global-search/global-search.state.spec.ts new file mode 100644 index 000000000..e204398a9 --- /dev/null +++ b/src/app/shared/stores/global-search/global-search.state.spec.ts @@ -0,0 +1,301 @@ +import { provideStore, Store } from '@ngxs/store'; + +import { EMPTY, of } from 'rxjs'; + +import { vi } from 'vitest'; + +import { TestBed } from '@angular/core/testing'; + +import { + DiscoverableFilter, + FilterOperatorOption, + FilterOption, +} from '@osf/shared/models/search/discoverable-filter.model'; +import { GlobalSearchService } from '@osf/shared/services/global-search.service'; +import { ResourcesData } from '@shared/models/search/resource.model'; + +import { provideOSFCore } from '@testing/osf.testing.provider'; + +import { + FetchResources, + LoadFilterOptions, + LoadFilterOptionsAndSetValues, + SetDefaultFilterValue, + SetExtraFilters, + UpdateSelectedFilterOption, +} from './global-search.actions'; +import { GlobalSearchSelectors } from './global-search.selectors'; +import { GlobalSearchState } from './global-search.state'; + +const MOCK_RESOURCES_DATA: ResourcesData = { + resources: [], + filters: [], + count: 0, + self: '', + first: null, + next: null, + previous: null, +}; + +const CEDAR_FILTER: DiscoverableFilter = { + key: 'School Type', + label: 'School Type', + operator: FilterOperatorOption.AnyOf, + cedarPropertyIri: 'uuid-school-type', + options: [ + { label: 'High School', value: 'High School', cardSearchResultCount: null }, + { label: 'Middle School', value: 'Middle School', cardSearchResultCount: null }, + ], +}; + +const REGULAR_FILTER: DiscoverableFilter = { + key: 'subject', + label: 'Subject', + operator: FilterOperatorOption.AnyOf, + resultCount: 10, +}; + +function setup() { + const mockGetResources = vi.fn().mockReturnValue(of(MOCK_RESOURCES_DATA)); + const mockGetFilterOptions = vi.fn().mockReturnValue(of({ options: [], nextUrl: undefined })); + + TestBed.configureTestingModule({ + providers: [ + provideOSFCore(), + provideStore([GlobalSearchState]), + { + provide: GlobalSearchService, + useValue: { + getResources: mockGetResources, + getFilterOptions: mockGetFilterOptions, + getResourcesByLink: vi.fn().mockReturnValue(EMPTY), + getFilterOptionsFromPaginationUrl: vi.fn().mockReturnValue(EMPTY), + }, + }, + ], + }); + + return { + store: TestBed.inject(Store), + mockGetResources, + mockGetFilterOptions, + }; +} + +describe('GlobalSearchState', () => { + describe('LoadFilterOptions', () => { + it('should skip the API call for a CEDAR filter (cedarPropertyIri set)', () => { + const { store, mockGetFilterOptions } = setup(); + + store.dispatch(new SetExtraFilters([CEDAR_FILTER])); + store.dispatch(new FetchResources()); + store.dispatch(new LoadFilterOptions(CEDAR_FILTER.key)); + + expect(mockGetFilterOptions).not.toHaveBeenCalled(); + }); + + it('should set isLoaded to true for a CEDAR filter when short-circuiting', () => { + const { store } = setup(); + + store.dispatch(new SetExtraFilters([CEDAR_FILTER])); + store.dispatch(new FetchResources()); + store.dispatch(new LoadFilterOptions(CEDAR_FILTER.key)); + + const filters = store.selectSnapshot(GlobalSearchSelectors.getFilters); + const cedarFilterState = filters.find((f) => f.key === CEDAR_FILTER.key); + expect(cedarFilterState?.isLoaded).toBe(true); + }); + + it('should call the API for a regular filter', () => { + const { store, mockGetFilterOptions } = setup(); + + store.dispatch(new FetchResources()); + store.dispatch(new LoadFilterOptions(REGULAR_FILTER.key)); + + expect(mockGetFilterOptions).toHaveBeenCalled(); + const params = mockGetFilterOptions.mock.calls[0][0]; + expect(params['valueSearchPropertyPath']).toBe(REGULAR_FILTER.key); + }); + }); + + describe('LoadFilterOptionsAndSetValues', () => { + it('should not call the API for CEDAR filter keys', () => { + const { store, mockGetFilterOptions } = setup(); + store.dispatch(new SetExtraFilters([CEDAR_FILTER])); + + store.dispatch( + new LoadFilterOptionsAndSetValues({ + [CEDAR_FILTER.key]: [{ label: 'High School', value: 'High School', cardSearchResultCount: null }], + }) + ); + + expect(mockGetFilterOptions).not.toHaveBeenCalled(); + }); + + it('should still set selectedFilterOptions for CEDAR keys', () => { + const { store } = setup(); + store.dispatch(new SetExtraFilters([CEDAR_FILTER])); + + const selectedOption: FilterOption = { label: 'High School', value: 'High School', cardSearchResultCount: null }; + store.dispatch(new LoadFilterOptionsAndSetValues({ [CEDAR_FILTER.key]: [selectedOption] })); + + const selected = store.selectSnapshot(GlobalSearchSelectors.getSelectedOptions); + expect(selected[CEDAR_FILTER.key]).toEqual([selectedOption]); + }); + + it('should only call the API for non-CEDAR keys in a mixed payload', () => { + const { store, mockGetFilterOptions } = setup(); + store.dispatch(new SetExtraFilters([CEDAR_FILTER])); + + store.dispatch( + new LoadFilterOptionsAndSetValues({ + [CEDAR_FILTER.key]: [{ label: 'High School', value: 'High School', cardSearchResultCount: null }], + [REGULAR_FILTER.key]: [{ label: 'Biology', value: 'biology', cardSearchResultCount: 5 }], + }) + ); + + expect(mockGetFilterOptions).toHaveBeenCalledTimes(1); + const params = mockGetFilterOptions.mock.calls[0][0]; + expect(params['valueSearchPropertyPath']).toBe(REGULAR_FILTER.key); + }); + }); + + describe('FetchResources (CEDAR filter params)', () => { + it('should add iriShorthand[cedar] when extraFilters are present', () => { + const { store, mockGetResources } = setup(); + store.dispatch(new SetExtraFilters([CEDAR_FILTER])); + + store.dispatch(new FetchResources()); + + const params = mockGetResources.mock.calls[0][0]; + expect(params['iriShorthand[cedar]']).toBe('https://schema.metadatacenter.org/properties/'); + }); + + it('should not add iriShorthand[cedar] when no extraFilters are present', () => { + const { store, mockGetResources } = setup(); + + store.dispatch(new FetchResources()); + + const params = mockGetResources.mock.calls[0][0]; + expect(params['iriShorthand[cedar]']).toBeUndefined(); + }); + + it('should use cardSearchText for a selected CEDAR filter value', () => { + const { store, mockGetResources } = setup(); + store.dispatch(new SetExtraFilters([CEDAR_FILTER])); + store.dispatch(new FetchResources()); // populates state.filters via updateResourcesState + mockGetResources.mockClear(); + + store.dispatch( + new LoadFilterOptionsAndSetValues({ + [CEDAR_FILTER.key]: [{ label: 'High School', value: 'High School', cardSearchResultCount: null }], + }) + ); + store.dispatch(new FetchResources()); + + const params = mockGetResources.mock.calls[0][0]; + expect(params[`cardSearchText[osf:hasCedarRecord.cedar:${CEDAR_FILTER.cedarPropertyIri}][]`]).toEqual([ + '"High School"', + ]); + expect(params[`cardSearchFilter[${CEDAR_FILTER.key}][]`]).toBeUndefined(); + }); + + it('should include all selected values for a CEDAR filter', () => { + const { store, mockGetResources } = setup(); + store.dispatch(new SetExtraFilters([CEDAR_FILTER])); + store.dispatch(new FetchResources()); + mockGetResources.mockClear(); + + store.dispatch( + new LoadFilterOptionsAndSetValues({ + [CEDAR_FILTER.key]: [ + { label: 'High School', value: 'High School', cardSearchResultCount: null }, + { label: 'Middle School', value: 'Middle School', cardSearchResultCount: null }, + ], + }) + ); + store.dispatch(new FetchResources()); + + const params = mockGetResources.mock.calls[0][0]; + expect(params[`cardSearchText[osf:hasCedarRecord.cedar:${CEDAR_FILTER.cedarPropertyIri}][]`]).toEqual([ + '"High School"', + '"Middle School"', + ]); + }); + + it('should use extraFilters as fallback for CEDAR lookup before state.filters is populated', () => { + const { store, mockGetResources } = setup(); + store.dispatch(new SetExtraFilters([CEDAR_FILTER])); + + store.dispatch( + new LoadFilterOptionsAndSetValues({ + [CEDAR_FILTER.key]: [{ label: 'High School', value: 'High School', cardSearchResultCount: null }], + }) + ); + // First FetchResources — state.filters is still empty at this point + store.dispatch(new FetchResources()); + + const params = mockGetResources.mock.calls[0][0]; + expect(params[`cardSearchText[osf:hasCedarRecord.cedar:${CEDAR_FILTER.cedarPropertyIri}][]`]).toEqual([ + '"High School"', + ]); + }); + }); + + describe('SetDefaultFilterValue', () => { + it('should include the default filter in the API call', () => { + const { store, mockGetResources } = setup(); + + store.dispatch(new SetDefaultFilterValue('defaultKey', 'default-value')); + store.dispatch(new FetchResources()); + + const params = mockGetResources.mock.calls[0][0]; + expect(params['cardSearchFilter[defaultKey][]']).toBe('default-value'); + }); + + it('should not be overridden when a selected filter for the same key is cleared', () => { + const { store, mockGetResources } = setup(); + + store.dispatch(new SetDefaultFilterValue('defaultKey', 'default-value')); + store.dispatch(new UpdateSelectedFilterOption('defaultKey', [])); + store.dispatch(new FetchResources()); + + const params = mockGetResources.mock.calls[0][0]; + expect(params['cardSearchFilter[defaultKey][]']).toBe('default-value'); + expect(params['cardSearchFilter[defaultKey][any-of]']).toBeUndefined(); + }); + + it('should AND the default value with an any-of clause for an explicitly selected value', () => { + const { store, mockGetResources } = setup(); + + store.dispatch(new SetDefaultFilterValue('defaultKey', 'default-value')); + store.dispatch( + new UpdateSelectedFilterOption('defaultKey', [ + { label: 'A', value: 'selected-value', cardSearchResultCount: null }, + ]) + ); + store.dispatch(new FetchResources()); + + const params = mockGetResources.mock.calls[0][0]; + expect(params['cardSearchFilter[defaultKey][]']).toBe('default-value'); + expect(params['cardSearchFilter[defaultKey][any-of]']).toBe('selected-value'); + }); + + it('should OR multiple selected values together via a single any-of clause', () => { + const { store, mockGetResources } = setup(); + + store.dispatch(new SetDefaultFilterValue('defaultKey', 'default-value')); + store.dispatch( + new UpdateSelectedFilterOption('defaultKey', [ + { label: 'A', value: 'selected-value-1', cardSearchResultCount: null }, + { label: 'B', value: 'selected-value-2', cardSearchResultCount: null }, + ]) + ); + store.dispatch(new FetchResources()); + + const params = mockGetResources.mock.calls[0][0]; + expect(params['cardSearchFilter[defaultKey][]']).toBe('default-value'); + expect(params['cardSearchFilter[defaultKey][any-of]']).toBe('selected-value-1,selected-value-2'); + }); + }); +}); 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 bf15f8ca0..eb94222ea 100644 --- a/src/app/shared/stores/global-search/global-search.state.ts +++ b/src/app/shared/stores/global-search/global-search.state.ts @@ -20,6 +20,7 @@ import { LoadMoreFilterOptions, ResetSearchState, SetDefaultFilterValue, + SetExtraFilters, SetResourceType, SetSearchText, SetSortBy, @@ -61,6 +62,15 @@ export class GlobalSearchState { loadFilterOptions(ctx: StateContext, action: LoadFilterOptions) { const state = ctx.getState(); const filterKey = action.filterKey; + + const filter = state.filters.find((f) => f.key === filterKey); + if (filter?.cedarPropertyIri) { + ctx.patchState({ + filters: state.filters.map((f) => (f.key === filterKey ? { ...f, isLoaded: true } : f)), + }); + return EMPTY; + } + const cachedOptions = state.filterOptionsCache[filterKey]; if (cachedOptions?.length) { const updatedFilters = state.filters.map((f) => @@ -203,7 +213,12 @@ export class GlobalSearchState { ctx.patchState({ filters: loadingFilters }); ctx.patchState({ selectedFilterOptions: filterValues }); - const observables = filterKeys.map((key) => + const cedarKeys = new Set(ctx.getState().extraFilters.map((f) => f.key)); + const nonCedarKeys = filterKeys.filter((key) => !cedarKeys.has(key)); + + if (!nonCedarKeys.length) return; + + const observables = nonCedarKeys.map((key) => this.searchService.getFilterOptions(this.buildParamsForIndexValueSearch(ctx.getState(), key)).pipe( tap((response) => { const options = response.options; @@ -239,6 +254,11 @@ export class GlobalSearchState { ctx.patchState({ defaultFilterOptions: updatedFilterValues }); } + @Action(SetExtraFilters) + setExtraFilters(ctx: StateContext, action: SetExtraFilters) { + ctx.patchState({ extraFilters: action.filters }); + } + @Action(UpdateSelectedFilterOption) updateSelectedFilterOption(ctx: StateContext, action: UpdateSelectedFilterOption) { const updatedFilterValues = { ...ctx.getState().selectedFilterOptions, [action.filterKey]: action.filterOption }; @@ -269,12 +289,16 @@ 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))]; + ctx.patchState({ resources: { data: response.resources, isLoading: false, error: null }, filterOptionsCache: {}, filterSearchCache: {}, filterPaginationCache: {}, - filters: response.filters, + filters: merged, resourcesCount: response.count, first: response.first, next: response.next, @@ -300,20 +324,38 @@ export class GlobalSearchState { Object.entries(state.defaultFilterOptions).forEach(([key, value]) => { filtersParams[`cardSearchFilter[${key}][]`] = value; }); - Object.entries(state.selectedFilterOptions).forEach(([key, options]) => { - const filter = state.filters.find((f) => f.key === key); + let hasCedarFilters = state.extraFilters.length > 0; - const firstOptionValue = options[0]?.value; - const isOptionValueBoolean = firstOptionValue === 'true' || firstOptionValue === 'false'; - if (filter?.operator === FilterOperatorOption.IsPresent || isOptionValueBoolean) { - if (firstOptionValue) { - filtersParams[`cardSearchFilter[${key}][is-present]`] = firstOptionValue; + Object.entries(state.selectedFilterOptions).forEach(([key, options]) => { + if (key in state.defaultFilterOptions && options.length === 0) return; + const filter = state.filters.find((f) => f.key === key) ?? state.extraFilters.find((f) => f.key === key); + + if (filter?.cedarPropertyIri) { + hasCedarFilters = true; + const values = options.map((o) => `"${o.value}"`); + if (values.length) { + filtersParams[`cardSearchText[osf:hasCedarRecord.cedar:${filter.cedarPropertyIri}][]`] = values; } } else { - filtersParams[`cardSearchFilter[${key}][]`] = options.map((option) => option.value); + const firstOptionValue = options[0]?.value; + const isOptionValueBoolean = firstOptionValue === 'true' || firstOptionValue === 'false'; + if (filter?.operator === FilterOperatorOption.IsPresent || isOptionValueBoolean) { + if (firstOptionValue) { + filtersParams[`cardSearchFilter[${key}][is-present]`] = firstOptionValue; + } + } else { + const selectedValues = options.map((option) => option.value); + if (selectedValues.length) { + filtersParams[`cardSearchFilter[${key}][any-of]`] = selectedValues.join(','); + } + } } }); + if (hasCedarFilters) { + filtersParams['iriShorthand[cedar]'] = 'https://schema.metadatacenter.org/properties/'; + } + filtersParams['cardSearchFilter[resourceType]'] = getResourceTypeStringFromEnum(state.resourceType); filtersParams['cardSearchFilter[accessService]'] = `${this.environment.webUrl}/`; filtersParams['cardSearchText[*,creator.name,isContainedBy.creator.name]'] = state.searchText diff --git a/src/assets/config/template.json b/src/assets/config/template.json index 826cb39c4..18f954f75 100644 --- a/src/assets/config/template.json +++ b/src/assets/config/template.json @@ -27,5 +27,6 @@ "newRelicLoaderConfigTrustKey": "", "newRelicLoaderConfigAgentID": "", "newRelicLoaderConfigLicenseKey": "", - "newRelicLoaderConfigApplicationID": "" + "newRelicLoaderConfigApplicationID": "", + "collectionSubmissionWithCedar": false } diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 452a5228a..d6ad8201e 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1,5 +1,35 @@ { + "truncatedText": { + "readMore": "Read more", + "hide": "Hide" + }, "activityLog": { + "defaults": { + "preprint": "Preprint", + "preprintPlural": "Preprints", + "anonymousA": "a", + "anonymousAn": "an anonymous", + "someUsers": "some users", + "contributorsAnd": ", and ", + "contributorsOthers": " others", + "aNewNameLocation": "a new name/location", + "materialized": "{{materialized}} in {{addon}}", + "aTitle": "a title", + "aNameLocation": "a name/location", + "pageTitle": "a title", + "aFile": "a file", + "folder": "folder", + "file": "file", + "aUser": "A user", + "aProject": "a project", + "fileOn": "on {{file}}", + "wikiOn": "on wiki page {{wiki}}", + "field": "field", + "updatedFields": "{{old}} to {{new}}", + "uncategorized": "Uncategorized", + "fallbackWithNode": "{{user}} performed action \"{{action}}\" on {{node}}", + "fallbackWithoutNode": "{{user}} performed action \"{{action}}\"" + }, "activities": { "addon_added": "{{user}} enabled {{addon}} to {{node}}", "addon_file_copied": "{{user}} copied {{source}} to {{destination}} in {{node}}", @@ -88,8 +118,8 @@ "registration_approved": "{{user}} approved a registration of {{node}}", "registration_approved_no_user": "Registration of {{node}} was approved", "registration_cancelled": "{{user}} cancelled a registration of {{node}}", - "registration_date_updated": "An OSF Support Team member updated the registration date of {{node}}", "registration_initiated": "{{user}} initiated a registration of {{node}}", + "registration_date_updated": "An OSF Support Team member updated the registration date of {{node}}", "resource_identifier_added": "{{user}} has added a Resource to Registration {{node}}", "resource_identifier_removed": "{{user}} has removed a Resource to Registration {{node}}", "resource_identifier_updated": "{{user}} has updated a Resource to Registration {{node}}", @@ -109,32 +139,6 @@ "wiki_deleted": "{{user}} deleted wiki page {{page}} of {{node}}", "wiki_renamed": "{{user}} renamed wiki page {{oldPage}} to {{page}} of {{node}}", "wiki_updated": "{{user}} updated wiki page {{page}} to version {{version}} of {{node}}" - }, - "defaults": { - "aFile": "a file", - "aNameLocation": "a name/location", - "aNewNameLocation": "a new name/location", - "anonymousA": "a", - "anonymousAn": "an anonymous", - "aProject": "a project", - "aTitle": "a title", - "aUser": "A user", - "contributorsAnd": ", and ", - "contributorsOthers": " others", - "fallbackWithNode": "{{user}} performed action \"{{action}}\" on {{node}}", - "fallbackWithoutNode": "{{user}} performed action \"{{action}}\"", - "field": "field", - "file": "file", - "fileOn": "on {{file}}", - "folder": "folder", - "materialized": "{{materialized}} in {{addon}}", - "pageTitle": "a title", - "preprint": "Preprint", - "preprintPlural": "Preprints", - "someUsers": "some users", - "uncategorized": "Uncategorized", - "updatedFields": "{{old}} to {{new}}", - "wikiOn": "on wiki page {{wiki}}" } }, "adminInstitutions": { @@ -1123,7 +1127,6 @@ "file_updated": { "instant": "You'll be notified immediately when files are updated.", "daily": "You'll receive a daily summary of file updates.", - "instant": "You'll be notified immediately when files are updated.", "none": "You won't receive file update notifications." } }, @@ -1234,10 +1237,10 @@ "hypothesis": "Hypothesis", "instrumentation": "Instrumentation", "methods and measures": "Methods and Measures", - "other": "Other", "procedure": "Procedure", "project": "Project", - "software": "Software" + "software": "Software", + "other": "Other" }, "pageNotFound": { "message": "The page you were looking for is either doesn't exist, was deleted, or the URL may be incorrect. If this should not have occurred and the issue persists, please report it to", diff --git a/src/testing/data/collections/cedar-metadata.mock.ts b/src/testing/data/collections/cedar-metadata.mock.ts new file mode 100644 index 000000000..2e8a5fc43 --- /dev/null +++ b/src/testing/data/collections/cedar-metadata.mock.ts @@ -0,0 +1,68 @@ +import { CedarMetadataDataTemplateJsonApi, CedarMetadataRecordData } from '@osf/features/metadata/models'; +import { CollectionSubmissionReviewState } from '@osf/shared/enums/collection-submission-review-state.enum'; +import { CollectionSubmission } from '@osf/shared/models/collections/collections.model'; + +export const MOCK_CEDAR_TEMPLATE: CedarMetadataDataTemplateJsonApi = { + id: 'template-1', + type: 'cedar-metadata-templates', + attributes: { + schema_name: 'Test Template', + cedar_id: 'cedar-1', + template: { + '@id': 'https://repo.metadatacenter.org/templates/1', + '@type': 'https://schema.metadatacenter.org/core/Template', + type: 'object', + title: 'Test', + description: 'Test template', + $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: {} }, + }, + is_for_collections: false, + }, +}; + +export const MOCK_CEDAR_RECORD: CedarMetadataRecordData = { + id: 'record-1', + attributes: { + metadata: { field: 'value' } 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' } }, + }, +}; + +export const MOCK_CEDAR_SUBMISSION: CollectionSubmission = { + id: '1', + type: 'collection-submission', + collectionTitle: 'Test Collection', + collectionId: 'collection-123', + reviewsState: CollectionSubmissionReviewState.Pending, + collectedType: 'preprint', + status: 'pending', + volume: '1', + issue: '1', + programArea: 'Science', + schoolType: 'University', + studyDesign: 'Experimental', + dataType: 'Quantitative', + disease: 'Cancer', + gradeLevels: 'Graduate', + requiredMetadataTemplateId: 'template-1', +}; diff --git a/src/testing/mocks/cedar-metadata-data-template-json-api.mock.ts b/src/testing/mocks/cedar-metadata-data-template-json-api.mock.ts index 755b01cc2..15e29a2d0 100644 --- a/src/testing/mocks/cedar-metadata-data-template-json-api.mock.ts +++ b/src/testing/mocks/cedar-metadata-data-template-json-api.mock.ts @@ -43,5 +43,6 @@ export const CEDAR_METADATA_DATA_TEMPLATE_JSON_API_MOCK: CedarMetadataTemplate = }, }, }, + is_for_collections: false, }, }; diff --git a/src/testing/providers/environment.token.mock.ts b/src/testing/providers/environment.token.mock.ts index 02105aed2..7bc33d525 100644 --- a/src/testing/providers/environment.token.mock.ts +++ b/src/testing/providers/environment.token.mock.ts @@ -47,5 +47,6 @@ export const EnvironmentTokenMock = { newRelicLoaderConfigAgentID: '', newRelicLoaderConfigLicenseKey: '', newRelicLoaderConfigApplicationID: '', + collectionSubmissionWithCedar: false, }, };