From de435f973dd087b34304a7b14ff695013f7e41f2 Mon Sep 17 00:00:00 2001 From: Yuhuai Liu Date: Tue, 19 May 2026 01:20:15 -0400 Subject: [PATCH] Revert "Feat/eng 9827 - save custom collection-submission metadata two ways" --- .../add-to-collection.component.html | 4 + .../add-to-collection.component.spec.ts | 404 +++----------- .../add-to-collection.component.ts | 63 ++- .../collection-metadata-step.component.html | 99 ++-- ...collection-metadata-step.component.spec.ts | 99 +++- .../collection-metadata-step.component.ts | 103 +++- .../collections-discover.component.spec.ts | 501 ++++++++---------- .../collections-discover.component.ts | 6 +- .../add-to-collection.state.ts | 4 +- .../cedar-template-form.component.ts | 5 +- .../helpers/cedar-metadata.helper.spec.ts | 171 ------ .../metadata/helpers/cedar-metadata.helper.ts | 35 -- ...llection-submission-item.component.spec.ts | 56 -- .../mappers/collections/collections.mapper.ts | 3 +- .../collections/collections-json-api.model.ts | 14 +- .../models/collections/collections.model.ts | 1 - .../shared/services/collections.service.ts | 21 +- src/app/shared/services/metadata.service.ts | 10 - .../global-search/global-search.state.ts | 11 +- 19 files changed, 637 insertions(+), 973 deletions(-) delete mode 100644 src/app/features/metadata/helpers/cedar-metadata.helper.spec.ts 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 5788553d0..b7c9645b7 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,137 +1,47 @@ -import { Store } from '@ngxs/store'; - import { MockComponents, MockProvider } from 'ng-mocks'; -import { Subject } from 'rxjs'; - -import { Mock } from 'vitest'; - -import { PLATFORM_ID } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormGroup } from '@angular/forms'; -import { ActivatedRoute, provideRouter, Router } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { UserSelectors } from '@core/store/user'; +import { CollectionMetadataStepComponent } from '@osf/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component'; +import { ProjectContributorsStepComponent } from '@osf/features/collections/components/add-to-collection/project-contributors-step/project-contributors-step.component'; +import { ProjectMetadataStepComponent } from '@osf/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component'; +import { SelectProjectStepComponent } from '@osf/features/collections/components/add-to-collection/select-project-step/select-project-step.component'; import { AddToCollectionSteps } from '@osf/features/collections/enums'; -import { - AddToCollectionSelectors, - ClearAddToCollectionState, - GetCurrentCollectionSubmission, - UpdateCollectionSubmission, -} from '@osf/features/collections/store/add-to-collection'; -import { LoadingSpinnerComponent } from '@shared/components/loading-spinner/loading-spinner.component'; -import { CollectionSubmissionReviewState } from '@shared/enums/collection-submission-review-state.enum'; -import { CollectionProjectSubmission, CollectionProvider } from '@shared/models/collections/collections.model'; -import { BrandService } from '@shared/services/brand.service'; -import { CustomDialogService } from '@shared/services/custom-dialog.service'; -import { HeaderStyleService } from '@shared/services/header-style.service'; -import { LoaderService } from '@shared/services/loader.service'; -import { ToastService } from '@shared/services/toast.service'; -import { CollectionsSelectors, GetCollectionProvider } from '@shared/stores/collections'; -import { ProjectsSelectors, SetSelectedProject } from '@shared/stores/projects'; +import { CedarRecordDataBinding } from '@osf/features/metadata/models'; +import { MetadataSelectors } from '@osf/features/metadata/store'; +import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; +import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; +import { ToastService } from '@osf/shared/services/toast.service'; +import { CollectionsSelectors } from '@shared/stores/collections'; +import { ProjectsSelectors } from '@shared/stores/projects/projects.selectors'; -import { MOCK_COLLECTION_SUBMISSION_1 } from '@testing/mocks/collections-submissions.mock'; import { MOCK_USER } from '@testing/mocks/data.mock'; import { MOCK_PROJECT } from '@testing/mocks/project.mock'; +import { MOCK_PROVIDER } from '@testing/mocks/provider.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; -import { BrandServiceMock, BrandServiceMockType } from '@testing/providers/brand-service.mock'; -import { - CustomDialogServiceMockBuilder, - CustomDialogServiceMockType, -} from '@testing/providers/custom-dialog-provider.mock'; -import { HeaderStyleServiceMock, HeaderStyleServiceMockType } from '@testing/providers/header-style-service.mock'; -import { LoaderServiceMock } from '@testing/providers/loader-service.mock'; +import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; -import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; -import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; +import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { AddToCollectionConfirmationDialogComponent } from './add-to-collection-confirmation-dialog/add-to-collection-confirmation-dialog.component'; -import { CollectionMetadataStepComponent } from './collection-metadata-step/collection-metadata-step.component'; -import { ProjectContributorsStepComponent } from './project-contributors-step/project-contributors-step.component'; -import { ProjectMetadataStepComponent } from './project-metadata-step/project-metadata-step.component'; -import { SelectProjectStepComponent } from './select-project-step/select-project-step.component'; import { AddToCollectionComponent } from './add-to-collection.component'; -const PROVIDER_ID = 'provider-1'; - -function createMockCollectionProvider(overrides: Partial = {}): CollectionProvider { - return { - id: PROVIDER_ID, - type: 'collection-providers', - name: 'Provider', - description: '', - domain: 'osf.io', - advisoryBoard: '', - allowCommenting: false, - allowSubmissions: true, - domainRedirectEnabled: false, - emailSupport: null, - example: null, - facebookAppId: null, - footerLinks: '', - permissions: [], - reviewsWorkflow: '', - sharePublishType: '', - shareSource: '', - assets: {}, - primaryCollection: { id: 'col-1', type: 'collections' }, - brand: null, - ...overrides, - } as CollectionProvider; -} - -const defaultSignals: SignalOverride[] = [ - { selector: CollectionsSelectors.getCollectionProviderLoading, value: false }, - { selector: CollectionsSelectors.getCollectionProvider, value: null }, - { selector: ProjectsSelectors.getSelectedProject, value: null }, - { selector: UserSelectors.getCurrentUser, value: MOCK_USER }, - { selector: AddToCollectionSelectors.getCurrentCollectionSubmission, value: null }, -]; - describe('AddToCollectionComponent', () => { let component: AddToCollectionComponent; let fixture: ComponentFixture; - let store: Store; - let routerMock: RouterMockType; - let customDialogMock: CustomDialogServiceMockType; - let dialogCloseSubject: Subject; - let brandServiceMock: BrandServiceMockType; - let headerStyleServiceMock: HeaderStyleServiceMockType; - let loaderServiceMock: LoaderServiceMock; - let toastServiceMock: ToastServiceMockType; + let mockRouter: ReturnType; + let mockActivatedRoute: ReturnType; + let mockCustomDialogService: ReturnType; - function setup( - options: { - routeParams?: Record; - hasParent?: boolean; - selectorOverrides?: SignalOverride[]; - platformId?: string; - } = {} - ) { - const routeBuilder = ActivatedRouteMockBuilder.create().withParams( - options.routeParams ?? { providerId: PROVIDER_ID } - ); - if (options.hasParent === false) { - routeBuilder.withNoParent(); - } - const mockRoute = routeBuilder.build(); - routerMock = RouterMockBuilder.create().withUrl('/collections/add').build(); - dialogCloseSubject = new Subject(); - customDialogMock = CustomDialogServiceMockBuilder.create() - .withOpen( - vi.fn().mockReturnValue({ - onClose: dialogCloseSubject.asObservable(), - close: vi.fn(), - }) - ) - .build(); - brandServiceMock = BrandServiceMock.simple(); - headerStyleServiceMock = HeaderStyleServiceMock.simple(); - loaderServiceMock = new LoaderServiceMock(); - toastServiceMock = ToastServiceMock.simple(); + const mockCollectionProvider = MOCK_PROVIDER; - const signals = mergeSignalOverrides(defaultSignals, options.selectorOverrides); + beforeEach(() => { + mockRouter = RouterMockBuilder.create().build(); + mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: null }).build(); + mockCustomDialogService = CustomDialogServiceMockBuilder.create().build(); TestBed.configureTestingModule({ imports: [ @@ -146,253 +56,109 @@ describe('AddToCollectionComponent', () => { ], providers: [ provideOSFCore(), - provideRouter([]), - MockProvider(ActivatedRoute, mockRoute), - MockProvider(Router, routerMock), - MockProvider(CustomDialogService, customDialogMock), - MockProvider(BrandService, brandServiceMock), - MockProvider(HeaderStyleService, headerStyleServiceMock), - MockProvider(LoaderService, loaderServiceMock), - MockProvider(ToastService, toastServiceMock), - MockProvider(PLATFORM_ID, options.platformId ?? 'browser'), - provideMockStore({ signals }), + MockProvider(ActivatedRoute, mockActivatedRoute), + MockProvider(Router, mockRouter), + MockProvider(CustomDialogService, mockCustomDialogService), + MockProvider(ToastService), + provideMockStore({ + signals: [ + { selector: CollectionsSelectors.getCollectionProviderLoading, value: false }, + { selector: CollectionsSelectors.getCollectionProvider, value: mockCollectionProvider }, + { selector: CollectionsSelectors.getRequiredMetadataTemplate, value: null }, + { selector: ProjectsSelectors.getSelectedProject, value: MOCK_PROJECT }, + { selector: UserSelectors.getCurrentUser, value: MOCK_USER }, + { selector: MetadataSelectors.getCedarRecords, value: [] }, + ], + }), ], }); - store = TestBed.inject(Store); fixture = TestBed.createComponent(AddToCollectionComponent); component = fixture.componentInstance; fixture.detectChanges(); - } + }); it('should create', () => { - setup(); expect(component).toBeTruthy(); }); - it('should navigate to not-found when providerId is missing', () => { - setup({ routeParams: {} }); - expect(routerMock.navigate).toHaveBeenCalledWith(['/not-found']); - }); - - it('should dispatch GetCollectionProvider when providerId is present', () => { - setup(); - expect(store.dispatch).toHaveBeenCalledWith(new GetCollectionProvider(PROVIDER_ID)); - }); - - it('should dispatch GetCurrentCollectionSubmission when route has project id and collection exists', () => { - setup({ - routeParams: { providerId: PROVIDER_ID, id: MOCK_PROJECT.id }, - selectorOverrides: [ - { selector: CollectionsSelectors.getCollectionProvider, value: createMockCollectionProvider() }, - ], - }); - expect(store.dispatch).toHaveBeenCalledWith(new GetCurrentCollectionSubmission('col-1', MOCK_PROJECT.id)); - }); - - it('should dispatch SetSelectedProject when submission has project and none selected', () => { - const submission: CollectionProjectSubmission = { - project: MOCK_PROJECT, - submission: { - ...MOCK_COLLECTION_SUBMISSION_1, - reviewsState: CollectionSubmissionReviewState.Pending, - }, - }; - setup({ - selectorOverrides: [ - { selector: CollectionsSelectors.getCollectionProvider, value: createMockCollectionProvider() }, - { selector: AddToCollectionSelectors.getCurrentCollectionSubmission, value: submission }, - ], - }); - expect(store.dispatch).toHaveBeenCalledWith(new SetSelectedProject(MOCK_PROJECT)); - }); - - it('should apply branding when collection provider has brand', () => { - const brand = { - id: 'b1', - name: 'B', - heroLogoImageUrl: 'https://x/h.png', - heroBackgroundImageUrl: 'https://x/hb.png', - topNavLogoImageUrl: 'https://x/n.png', - primaryColor: '#111', - secondaryColor: '#222', - backgroundColor: '#333', - }; - setup({ - selectorOverrides: [ - { selector: CollectionsSelectors.getCollectionProvider, value: createMockCollectionProvider({ brand }) }, - ], - }); - expect(brandServiceMock.applyBranding).toHaveBeenCalledWith(brand); - expect(headerStyleServiceMock.applyHeaderStyles).toHaveBeenCalledWith('#222', '#333'); + it('should initialize with default values', () => { + expect(component.stepperActiveValue()).toBe(AddToCollectionSteps.SelectProject); + expect(component.projectMetadataSaved()).toBe(false); + expect(component.projectContributorsSaved()).toBe(false); + expect(component.collectionMetadataSaved()).toBe(false); + expect(component.allowNavigation()).toBe(false); }); - it('should reset saved flags when project is selected', () => { - setup(); - component.projectMetadataSaved.set(true); - component.projectContributorsSaved.set(true); - component.allowNavigation.set(true); + it('should handle project selection', () => { component.handleProjectSelected(); - expect(component.projectMetadataSaved()).toBe(false); + expect(component.projectContributorsSaved()).toBe(false); + expect(component.projectMetadataSaved()).toBe(false); expect(component.allowNavigation()).toBe(false); }); - it('should update stepper value on step change', () => { - setup(); - component.handleChangeStep(AddToCollectionSteps.ProjectMetadata); - expect(component.stepperActiveValue()).toBe(AddToCollectionSteps.ProjectMetadata); + it('should handle step change', () => { + const newStep = AddToCollectionSteps.ProjectMetadata; + component.handleChangeStep(newStep); + + expect(component.stepperActiveValue()).toBe(newStep); }); - it('should mark project metadata saved', () => { - setup(); + it('should handle project metadata saved', () => { component.handleProjectMetadataSaved(); + expect(component.projectMetadataSaved()).toBe(true); }); - it('should mark contributors saved and move to collection metadata step', () => { - setup(); + it('should handle contributors saved', () => { component.handleContributorsSaved(); - expect(component.projectContributorsSaved()).toBe(true); + expect(component.stepperActiveValue()).toBe(AddToCollectionSteps.CollectionMetadata); + expect(component.projectContributorsSaved()).toBe(true); }); - it('should store collection metadata form and complete step', () => { - setup(); - const form = new FormGroup({}); - component.handleCollectionMetadataSaved(form); - expect(component.collectionMetadataForm).toBe(form); + it('should handle collection metadata saved', () => { + const mockForm = new FormGroup({}); + component.handleCollectionMetadataSaved(mockForm); + + expect(component.collectionMetadataForm).toBe(mockForm); expect(component.collectionMetadataSaved()).toBe(true); expect(component.stepperActiveValue()).toBe(AddToCollectionSteps.Complete); }); - it('should return true from canDeactivate when navigation is allowed', () => { - setup(); - component.allowNavigation.set(true); - expect(component.canDeactivate()).toBe(true); - }); - - it('should return true from canDeactivate when there are no unsaved changes', () => { - setup(); - expect(component.canDeactivate()).toBe(true); - }); - - it('should return false from canDeactivate when there are unsaved changes', () => { - setup({ - selectorOverrides: [{ selector: ProjectsSelectors.getSelectedProject, value: MOCK_PROJECT }], - }); - expect(component.canDeactivate()).toBe(false); - }); - - it('should warn on beforeunload when there are unsaved changes', () => { - setup({ - selectorOverrides: [{ selector: ProjectsSelectors.getSelectedProject, value: MOCK_PROJECT }], - }); - const event = { preventDefault: vi.fn() } as unknown as BeforeUnloadEvent; - const result = component.onBeforeUnload(event); - expect(event.preventDefault).toHaveBeenCalled(); - expect(result).toBe(false); - }); - - it('should not prevent beforeunload when navigation is allowed', () => { - setup({ - selectorOverrides: [{ selector: ProjectsSelectors.getSelectedProject, value: MOCK_PROJECT }], - }); - component.allowNavigation.set(true); - const event = { preventDefault: vi.fn() } as unknown as BeforeUnloadEvent; - const result = component.onBeforeUnload(event); - expect(event.preventDefault).not.toHaveBeenCalled(); - expect(result).toBeUndefined(); - }); + it('should handle cedar data saved', () => { + const mockCedarData: CedarRecordDataBinding = { + data: {} as CedarRecordDataBinding['data'], + id: 'template-123', + isPublished: false, + }; + component.handleCedarDataSaved(mockCedarData); - it('should open confirmation dialog when adding in create mode', () => { - setup({ - selectorOverrides: [ - { selector: CollectionsSelectors.getCollectionProvider, value: createMockCollectionProvider() }, - { selector: ProjectsSelectors.getSelectedProject, value: MOCK_PROJECT }, - ], - }); - component.handleCollectionMetadataSaved(new FormGroup({})); - component.handleAddToCollection(); - expect(customDialogMock.open).toHaveBeenCalledWith( - AddToCollectionConfirmationDialogComponent, - expect.objectContaining({ - header: 'collections.addToCollection.confirmationDialogHeader', - width: '500px', - data: expect.objectContaining({ - project: MOCK_PROJECT, - payload: expect.objectContaining({ - collectionId: 'col-1', - projectId: MOCK_PROJECT.id, - userId: MOCK_USER.id, - }), - }), - }) - ); + expect(component.pendingCedarData()).toEqual(mockCedarData); + expect(component.collectionMetadataSaved()).toBe(true); + expect(component.stepperActiveValue()).toBe(AddToCollectionSteps.Complete); }); - it('should navigate after confirmation dialog closes with a truthy result', () => { - setup({ - selectorOverrides: [ - { selector: CollectionsSelectors.getCollectionProvider, value: createMockCollectionProvider() }, - { selector: ProjectsSelectors.getSelectedProject, value: MOCK_PROJECT }, - ], - }); - component.handleCollectionMetadataSaved(new FormGroup({})); - component.handleAddToCollection(); - dialogCloseSubject.next(true); - expect(routerMock.navigate).toHaveBeenCalledWith([MOCK_PROJECT.id, 'overview']); + it('should have actions defined', () => { + expect(component.actions).toBeDefined(); + expect(component.actions.getCollectionProvider).toBeDefined(); + expect(component.actions.clearAddToCollectionState).toBeDefined(); }); - it('should update submission in edit mode and navigate on success', () => { - setup({ - routeParams: { providerId: PROVIDER_ID, id: MOCK_PROJECT.id }, - selectorOverrides: [ - { selector: CollectionsSelectors.getCollectionProvider, value: createMockCollectionProvider() }, - { selector: ProjectsSelectors.getSelectedProject, value: MOCK_PROJECT }, - ], - }); - component.handleCollectionMetadataSaved(new FormGroup({})); - (store.dispatch as Mock).mockClear(); - component.handleAddToCollection(); - expect(loaderServiceMock.show).toHaveBeenCalled(); - expect(loaderServiceMock.hide).toHaveBeenCalled(); - expect(store.dispatch).toHaveBeenCalledWith( - new UpdateCollectionSubmission({ - collectionId: 'col-1', - projectId: MOCK_PROJECT.id, - collectionMetadata: {}, - userId: MOCK_USER.id, - }) - ); - expect(toastServiceMock.showSuccess).toHaveBeenCalledWith( - 'collections.addToCollection.confirmationDialogToastMessage' - ); - expect(routerMock.navigate).toHaveBeenCalledWith([MOCK_PROJECT.id, 'overview']); + it('should handle loading state', () => { + expect(component.isProviderLoading()).toBe(false); }); - it('should not open remove dialog when project is missing', () => { - setup({ - routeParams: { providerId: PROVIDER_ID, id: MOCK_PROJECT.id }, - selectorOverrides: [ - { selector: CollectionsSelectors.getCollectionProvider, value: createMockCollectionProvider() }, - ], - }); - component.handleRemoveFromCollection(); - expect(customDialogMock.open).not.toHaveBeenCalled(); + it('should have collection provider data', () => { + expect(component.collectionProvider()).toEqual(mockCollectionProvider); }); - it('should clear state on destroy in browser', () => { - setup(); - (store.dispatch as Mock).mockClear(); - fixture.destroy(); - expect(store.dispatch).toHaveBeenCalledWith(expect.any(ClearAddToCollectionState)); + it('should have selected project data', () => { + expect(component.selectedProject()).toEqual(MOCK_PROJECT); }); - it('should not dispatch clear state on destroy when not in browser', () => { - setup({ platformId: 'server' }); - (store.dispatch as Mock).mockClear(); - fixture.destroy(); - expect(store.dispatch).not.toHaveBeenCalled(); + it('should have current user data', () => { + expect(component.currentUser()).toEqual(MOCK_USER); }); }); 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 307bab0e8..c90a8cee2 100644 --- a/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts +++ b/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts @@ -23,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'; @@ -81,6 +90,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 +102,18 @@ export class AddToCollectionComponent implements CanDeactivateComponent { isProviderLoading = select(CollectionsSelectors.getCollectionProviderLoading); collectionProvider = select(CollectionsSelectors.getCollectionProvider); + requiredMetadataTemplate = select(CollectionsSelectors.getRequiredMetadataTemplate); selectedProject = select(ProjectsSelectors.getSelectedProject); currentUser = select(UserSelectors.getCurrentUser); currentCollectionSubmission = select(AddToCollectionSelectors.getCurrentCollectionSubmission); + cedarRecords = select(MetadataSelectors.getCedarRecords); providerId = signal(''); allowNavigation = signal(false); projectMetadataSaved = signal(false); projectContributorsSaved = signal(false); collectionMetadataSaved = signal(false); + pendingCedarData = signal(null); stepperActiveValue = signal(AddToCollectionSteps.SelectProject); primaryCollectionId = computed(() => this.collectionProvider()?.primaryCollection?.id); @@ -110,6 +123,13 @@ export class AddToCollectionComponent implements CanDeactivateComponent { isCollectionMetadataDisabled = computed( () => !this.selectedProject() || !this.projectMetadataSaved() || !this.projectContributorsSaved() ); + isCedarMode = computed(() => this.environment.collectionSubmissionWithCedar && !!this.requiredMetadataTemplate()); + existingCedarRecord = computed(() => { + const records = this.cedarRecords(); + const templateId = this.requiredMetadataTemplate()?.id; + if (!records?.length || !templateId) return null; + return records.find((r) => r.relationships?.template?.data?.id === templateId) ?? null; + }); readonly actions = createDispatchMap({ getCollectionProvider: GetCollectionProvider, @@ -118,6 +138,9 @@ export class AddToCollectionComponent implements CanDeactivateComponent { deleteCollectionSubmission: RemoveCollectionSubmission, setSelectedProject: SetSelectedProject, getCurrentCollectionSubmission: GetCurrentCollectionSubmission, + getCedarRecords: GetCedarMetadataRecords, + createCedarRecord: CreateCedarMetadataRecord, + updateCedarRecord: UpdateCedarMetadataRecord, }); showRemoveButton = computed( @@ -174,20 +197,29 @@ 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 || {}, + collectionMetadata: this.isCedarMode() ? {} : this.collectionMetadataForm.value || {}, userId: this.currentUser()?.id || '', }; - if (this.isEditMode()) { + const isEditMode = this.isEditMode(); + + if (isEditMode) { this.loaderService.show(); this.actions .updateCollectionSubmission(payload) .pipe( + switchMap(() => this.saveCedarRecordIfNeeded()), finalize(() => this.loaderService.hide()), takeUntilDestroyed(this.destroyRef) ) @@ -210,6 +242,7 @@ export class AddToCollectionComponent implements CanDeactivateComponent { }) .onClose.pipe( filter((res) => !!res), + switchMap(() => this.saveCedarRecordIfNeeded()), takeUntilDestroyed(this.destroyRef) ) .subscribe({ @@ -245,21 +278,35 @@ export class AddToCollectionComponent implements CanDeactivateComponent { collectionId, comment: res?.comment || '', }; - this.loaderService.show(); + return this.actions.deleteCollectionSubmission(payload); }), - finalize(() => this.loaderService.hide()), takeUntilDestroyed(this.destroyRef) ) .subscribe({ next: () => { this.toastService.showSuccess('collections.removeDialog.success'); + this.loaderService.show(); this.allowNavigation.set(true); this.router.navigate([projectId, 'overview']); }, }); } + private saveCedarRecordIfNeeded(): Observable { + if (!this.isCedarMode()) return of(null); + + const cedarData = this.pendingCedarData(); + const projectId = this.selectedProject()?.id; + const templateId = this.requiredMetadataTemplate()?.id; + if (!cedarData || !projectId || !templateId) return of(null); + + const existingId = this.existingCedarRecord()?.id; + return existingId + ? this.actions.updateCedarRecord(cedarData, existingId, projectId, ResourceType.Project) + : this.actions.createCedarRecord(cedarData, projectId, ResourceType.Project); + } + private initializeProvider(): void { const id = this.route.snapshot.paramMap.get('providerId'); if (!id) { @@ -298,6 +345,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 8f568269e..f6dc67b64 100644 --- a/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.spec.ts +++ b/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.spec.ts @@ -7,8 +7,10 @@ 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'; @@ -18,7 +20,7 @@ describe('CollectionMetadataStepComponent', () => { let component: CollectionMetadataStepComponent; let fixture: ComponentFixture; - function setup() { + function setup(isCedarMode = false, cedarTemplate: CedarMetadataDataTemplateJsonApi | null = null) { TestBed.configureTestingModule({ imports: [CollectionMetadataStepComponent, MockComponents(StepPanel, Step, StepItem)], providers: [ @@ -41,6 +43,10 @@ describe('CollectionMetadataStepComponent', () => { fixture.componentRef.setInput('targetStepValue', 1); fixture.componentRef.setInput('isDisabled', false); fixture.componentRef.setInput('primaryCollectionId', 'test-collection-id'); + fixture.componentRef.setInput('isCedarMode', isCedarMode); + if (cedarTemplate) { + fixture.componentRef.setInput('cedarTemplate', cedarTemplate); + } fixture.detectChanges(); } @@ -57,6 +63,7 @@ describe('CollectionMetadataStepComponent', () => { expect(component.stepperActiveValue()).toBe(0); expect(component.targetStepValue()).toBe(1); expect(component.isDisabled()).toBe(false); + expect(component.isCedarMode()).toBe(false); }); it('should handle save metadata in filter mode', () => { @@ -118,4 +125,94 @@ describe('CollectionMetadataStepComponent', () => { expect(component.targetStepValue()).toBe(3); expect(component.isDisabled()).toBe(true); }); + + describe('CEDAR mode', () => { + beforeEach(() => { + setup(true, MOCK_CEDAR_TEMPLATE); + }); + + it('should initialize in CEDAR mode', () => { + expect(component.isCedarMode()).toBe(true); + expect(component.cedarTemplate()).toEqual(MOCK_CEDAR_TEMPLATE); + }); + + it('should handle discard changes in CEDAR mode', () => { + component.cedarFormData.set({ field: 'value' }); + component.collectionMetadataSaved.set(true); + + component.handleDiscardChanges(); + + expect(component.collectionMetadataSaved()).toBe(false); + expect(component.cedarFormData()).toEqual({}); + }); + + it('should handle discard changes with existing record in CEDAR mode', () => { + const existingRecord: CedarMetadataRecordData = { + attributes: { + metadata: { field: 'original' } as unknown as CedarMetadataRecordData['attributes']['metadata'], + is_published: false, + }, + relationships: { + template: { data: { type: 'cedar-metadata-templates', id: 'template-1' } }, + target: { data: { type: 'nodes', id: 'node-1' } }, + }, + }; + fixture.componentRef.setInput('existingCedarRecord', existingRecord); + fixture.detectChanges(); + + component.collectionMetadataSaved.set(true); + component.handleDiscardChanges(); + + expect(component.collectionMetadataSaved()).toBe(false); + }); + + it('should populate cedarFormData from existingCedarRecord', () => { + const existingRecord: CedarMetadataRecordData = { + attributes: { + metadata: { field: 'existing' } as unknown as CedarMetadataRecordData['attributes']['metadata'], + is_published: true, + }, + relationships: { + template: { data: { type: 'cedar-metadata-templates', id: 'template-1' } }, + target: { data: { type: 'nodes', id: 'node-1' } }, + }, + }; + fixture.componentRef.setInput('existingCedarRecord', existingRecord); + fixture.detectChanges(); + + expect(component.cedarFormData()).toEqual({ field: 'existing' }); + }); + + it('should emit cedarDataSaved when handleSaveCedarMetadata is called without editor', () => { + const cedarDataSavedSpy = vi.spyOn(component.cedarDataSaved, 'emit'); + const stepChangeSpy = vi.spyOn(component.stepChange, 'emit'); + + component.handleSaveCedarMetadata(); + + expect(cedarDataSavedSpy).not.toHaveBeenCalled(); + expect(stepChangeSpy).not.toHaveBeenCalled(); + }); + + it('should handle onCedarChange event', () => { + const mockMetadata = { field: 'changed' }; + const mockEditor = { currentMetadata: mockMetadata } as unknown as EventTarget; + const mockEvent = new CustomEvent('change'); + Object.defineProperty(mockEvent, 'target', { value: mockEditor }); + + component.onCedarChange(mockEvent); + + expect(component.cedarFormData()).toEqual(mockMetadata); + }); + + it('should not call handleSaveCedarMetadata without template', () => { + fixture.componentRef.setInput('cedarTemplate', null); + fixture.detectChanges(); + + const cedarDataSavedSpy = vi.spyOn(component.cedarDataSaved, 'emit'); + + component.handleSaveCedarMetadata(); + + expect(cedarDataSavedSpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.ts b/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.ts index 5c57c30d9..b4fe45f64 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,14 +66,25 @@ export class CollectionMetadataStepComponent { targetStepValue = input.required(); isDisabled = input.required(); primaryCollectionId = input(); + isCedarMode = input(false); + cedarTemplate = input(null); + existingCedarRecord = input(null); stepChange = output(); metadataSaved = output(); + cedarDataSaved = output(); collectionMetadataForm = signal(new FormGroup({})); collectionMetadataSaved = signal(false); originalFormValues = signal>({}); formPopulatedFromSubmission = signal(false); + cedarFormData = signal>({}); + + cedarConfig = CEDAR_CONFIG; + cedarViewerConfig = CEDAR_VIEWER_CONFIG; + + cedarEditor = viewChild>('cedarEditor'); + cedarViewer = viewChild>('cedarViewer'); private readonly actions = createDispatchMap({ getCollectionDetails: GetCollectionDetails }); @@ -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,39 @@ export class CollectionMetadataStepComponent { this.stepChange.emit(AddToCollectionSteps.Complete); } + handleSaveCedarMetadata() { + const editor = this.cedarEditor()?.nativeElement; + const template = this.cedarTemplate(); + if (!editor || !template) return; + + const currentMetadata = editor.currentMetadata; + const isValid = !!editor.dataQualityReport?.isValid; + + if (currentMetadata) { + this.cedarFormData.set(currentMetadata as Record); + } + + const cedarData: CedarRecordDataBinding = { + data: currentMetadata as CedarRecordDataBinding['data'], + id: template.id, + isPublished: isValid, + }; + + this.collectionMetadataSaved.set(true); + this.cedarDataSaved.emit(cedarData); + this.stepChange.emit(AddToCollectionSteps.Complete); + } + + onCedarChange(event: Event): void { + const customEvent = event as CustomEvent; + if (customEvent?.target) { + const editor = customEvent.target as CedarEditorElement; + if (editor && typeof editor.currentMetadata !== 'undefined') { + this.cedarFormData.set(editor.currentMetadata as Record); + } + } + } + private buildCollectionMetadataForm() { const filterEntries = this.availableFilterEntries(); const formControls: Record = {}; @@ -115,9 +193,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 +223,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 +233,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/collections-discover/collections-discover.component.spec.ts b/src/app/features/collections/components/collections-discover/collections-discover.component.spec.ts index 86f448e72..7be0470cd 100644 --- a/src/app/features/collections/components/collections-discover/collections-discover.component.spec.ts +++ b/src/app/features/collections/components/collections-discover/collections-discover.component.spec.ts @@ -4,345 +4,258 @@ import { MockComponents, MockProvider } from 'ng-mocks'; import { Mock } from 'vitest'; -import { PLATFORM_ID } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute, provideRouter, Router } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { ENVIRONMENT } from '@core/provider/environment.provider'; -import { GlobalSearchComponent } from '@shared/components/global-search/global-search.component'; -import { LoadingSpinnerComponent } from '@shared/components/loading-spinner/loading-spinner.component'; -import { SearchInputComponent } from '@shared/components/search-input/search-input.component'; -import { CollectionDetails, CollectionProvider } from '@shared/models/collections/collections.model'; -import { EnvironmentModel } from '@shared/models/environment.model'; -import { FilterOperatorOption } from '@shared/models/search/discoverable-filter.model'; -import { BrandService } from '@shared/services/brand.service'; -import { CustomDialogService } from '@shared/services/custom-dialog.service'; -import { HeaderStyleService } from '@shared/services/header-style.service'; -import { - CollectionsSelectors, - GetCollectionDetails, - GetCollectionProvider, - SearchCollectionSubmissions, - SetPageNumber, - SetSearchValue, -} from '@shared/stores/collections'; -import { ResetSearchState, SetDefaultFilterValue, SetExtraFilters } from '@shared/stores/global-search'; - -import { CEDAR_METADATA_DATA_TEMPLATE_JSON_API_MOCK } from '@testing/mocks/cedar-metadata-data-template-json-api.mock'; -import { MOCK_COLLECTIONS_EMPTY_FILTERS } from '@testing/mocks/collections-filters.mock'; +import { 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 { BrandServiceMock, BrandServiceMockType } from '@testing/providers/brand-service.mock'; -import { CustomDialogServiceMock, CustomDialogServiceMockType } from '@testing/providers/custom-dialog-provider.mock'; -import { EnvironmentTokenMock } from '@testing/providers/environment.token.mock'; -import { HeaderStyleServiceMock, HeaderStyleServiceMockType } from '@testing/providers/header-style-service.mock'; +import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; -import { mergeSignalOverrides, provideMockStore, SignalOverride } from '@testing/providers/store-provider.mock'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; +import { ToastServiceMock } from '@testing/providers/toast-provider.mock'; import { CollectionsQuerySyncService } from '../../services'; -import { CollectionsHelpDialogComponent } from '../collections-help-dialog/collections-help-dialog.component'; import { CollectionsMainContentComponent } from '../collections-main-content/collections-main-content.component'; import { CollectionsDiscoverComponent } from './collections-discover.component'; -const PROVIDER_ID = 'provider-1'; - -const mockCollectionDetails: CollectionDetails = { - id: 'col-1', - type: 'collections', - title: 'Collection', - dateCreated: '2024-01-01T00:00:00Z', - dateModified: '2024-01-02T00:00:00Z', - bookmarks: false, - isPromoted: false, - isPublic: true, - filters: { - collectedType: [], - disease: [], - dataType: [], - gradeLevels: [], - issue: [], - programArea: [], - schoolType: [], - status: [], - studyDesign: [], - volume: [], +const MOCK_COLLECTION_PROVIDER = { + ...MOCK_PROVIDER, + primaryCollection: { id: 'collection-1', type: 'collections' }, + requiredMetadataTemplate: null, +}; + +const MOCK_COLLECTION_PROVIDER_WITH_TEMPLATE = { + ...MOCK_COLLECTION_PROVIDER, + requiredMetadataTemplate: { + id: 'template-1', + type: 'cedar-metadata-templates' as const, + attributes: { + schema_name: 'Test', + cedar_id: 'cedar-1', + template: { + '@id': 'https://repo.metadatacenter.org/templates/test', + '@type': 'https://schema.metadatacenter.org/core/Template', + type: 'object', + title: 'Test', + description: '', + $schema: 'http://json-schema.org/draft-04/schema', + '@context': {} as never, + required: [], + properties: {}, + _ui: { + order: ['field1'], + propertyLabels: { field1: 'Field One' }, + propertyDescriptions: {}, + }, + }, + }, }, }; -function createMockCollectionProvider(overrides: Partial = {}): CollectionProvider { - return { - id: PROVIDER_ID, - type: 'collection-providers', - name: 'Provider', - description: '', - domain: 'osf.io', - advisoryBoard: '', - allowCommenting: false, - allowSubmissions: true, - domainRedirectEnabled: false, - emailSupport: null, - example: null, - facebookAppId: null, - footerLinks: '', - permissions: [], - reviewsWorkflow: '', - sharePublishType: '', - shareSource: '', - iri: 'https://api.test.osf.io/v2/collections/col-1/', - assets: {}, - primaryCollection: { id: 'col-1', type: 'collections' }, - brand: null, - ...overrides, - } as CollectionProvider; +interface SetupOptions { + collectionSubmissionWithCedar?: boolean; + provider?: typeof MOCK_COLLECTION_PROVIDER | typeof MOCK_COLLECTION_PROVIDER_WITH_TEMPLATE; } -const defaultSignals: SignalOverride[] = [ - { selector: CollectionsSelectors.getCollectionProviderLoading, value: false }, - { selector: CollectionsSelectors.getCollectionProvider, value: null }, - { selector: CollectionsSelectors.getCollectionDetails, value: null }, - { selector: CollectionsSelectors.getAllSelectedFilters, value: { ...MOCK_COLLECTIONS_EMPTY_FILTERS } }, - { selector: CollectionsSelectors.getSortBy, value: '' }, - { selector: CollectionsSelectors.getSearchText, value: '' }, - { selector: CollectionsSelectors.getPageNumber, value: '1' }, -]; +function setup(options: SetupOptions = {}) { + const { collectionSubmissionWithCedar = false, provider = MOCK_COLLECTION_PROVIDER } = options; + + const toastServiceMock = ToastServiceMock.simple(); + const mockCustomDialogService = CustomDialogServiceMockBuilder.create().build(); + const mockRoute = ActivatedRouteMockBuilder.create().withParams({ providerId: 'provider-1' }).build(); + + TestBed.configureTestingModule({ + imports: [ + CollectionsDiscoverComponent, + ...MockComponents( + SearchInputComponent, + CollectionsMainContentComponent, + GlobalSearchComponent, + LoadingSpinnerComponent + ), + ], + providers: [ + provideOSFCore(), + { provide: ENVIRONMENT, useValue: { apiDomainUrl: 'http://localhost:8000', collectionSubmissionWithCedar } }, + MockProvider(ToastService, toastServiceMock), + MockProvider(CustomDialogService, mockCustomDialogService), + MockProvider(ActivatedRoute, mockRoute), + provideMockStore({ + signals: [ + { selector: CollectionsSelectors.getCollectionProvider, value: provider }, + { selector: CollectionsSelectors.getCollectionDetails, value: null }, + { selector: CollectionsSelectors.getAllSelectedFilters, value: {} }, + { selector: CollectionsSelectors.getSortBy, value: 'date' }, + { selector: CollectionsSelectors.getSearchText, value: '' }, + { selector: CollectionsSelectors.getPageNumber, value: '1' }, + { selector: CollectionsSelectors.getCollectionProviderLoading, value: false }, + ], + }), + ], + }).overrideComponent(CollectionsDiscoverComponent, { + set: { + providers: [MockProvider(CollectionsQuerySyncService)], + }, + }); + + const fixture = TestBed.createComponent(CollectionsDiscoverComponent); + const component = fixture.componentInstance; + const store = TestBed.inject(Store); + fixture.detectChanges(); + + return { fixture, component, store }; +} describe('CollectionsDiscoverComponent', () => { - let component: CollectionsDiscoverComponent; - let fixture: ComponentFixture; - let store: Store; - let routerMock: RouterMockType; - let customDialogMock: CustomDialogServiceMockType; - let querySyncMock: Partial; - let brandServiceMock: BrandServiceMockType; - let headerStyleServiceMock: HeaderStyleServiceMockType; - - function setup( - options: { - routeParams?: Record; - hasParent?: boolean; - selectorOverrides?: SignalOverride[]; - useCedarEnvironment?: boolean; - platformId?: string; - } = {} - ) { - const routeBuilder = ActivatedRouteMockBuilder.create().withParams( - options.routeParams ?? { providerId: PROVIDER_ID } - ); - if (options.hasParent === false) { - routeBuilder.withNoParent(); - } - const mockRoute = routeBuilder.build(); - routerMock = RouterMockBuilder.create().withUrl('/collections/discover').build(); - customDialogMock = CustomDialogServiceMock.simple(); - querySyncMock = { - initializeFromUrl: vi.fn(), - syncStoreToUrl: vi.fn(), - }; - brandServiceMock = BrandServiceMock.simple(); - headerStyleServiceMock = HeaderStyleServiceMock.simple(); - - const envValue = { - ...EnvironmentTokenMock.useValue, - collectionSubmissionWithCedar: options.useCedarEnvironment ?? false, - } as unknown as EnvironmentModel; - - const signals = mergeSignalOverrides(defaultSignals, options.selectorOverrides); - - TestBed.configureTestingModule({ - imports: [ - CollectionsDiscoverComponent, - ...MockComponents( - SearchInputComponent, - CollectionsMainContentComponent, - GlobalSearchComponent, - LoadingSpinnerComponent - ), - ], - providers: [ - provideOSFCore(), - provideRouter([]), - MockProvider(ActivatedRoute, mockRoute), - MockProvider(Router, routerMock), - MockProvider(CustomDialogService, customDialogMock), - MockProvider(BrandService, brandServiceMock), - MockProvider(HeaderStyleService, headerStyleServiceMock), - MockProvider(PLATFORM_ID, options.platformId ?? 'browser'), - MockProvider(ENVIRONMENT, envValue), - provideMockStore({ signals }), - ], - }).overrideComponent(CollectionsDiscoverComponent, { - set: { - providers: [MockProvider(CollectionsQuerySyncService, querySyncMock)], - }, + describe('legacy mode (collectionSubmissionWithCedar = false)', () => { + let component: CollectionsDiscoverComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + ({ fixture, component } = setup()); }); - store = TestBed.inject(Store); - fixture = TestBed.createComponent(CollectionsDiscoverComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - } + it('should create', () => { + expect(component).toBeTruthy(); + }); - it('should create', () => { - setup(); - expect(component).toBeTruthy(); - }); + it('should set useShareTroveSearch to false', () => { + expect(component.useShareTroveSearch).toBe(false); + }); - it('should initialize searchControl with empty string', () => { - setup(); - expect(component.searchControl.value).toBe(''); - }); + it('should initialize with default values', () => { + expect(component.providerId()).toBe('provider-1'); + expect(component.searchControl.value).toBe(''); + }); - it('should navigate to not-found when providerId param is missing', () => { - setup({ routeParams: {} }); - expect(routerMock.navigate).toHaveBeenCalledWith(['/not-found']); - }); + it('should have collection provider data', () => { + expect(component.collectionProvider()).toEqual(MOCK_COLLECTION_PROVIDER); + }); - it('should dispatch GetCollectionProvider when providerId is present', () => { - setup({ routeParams: { providerId: 'my-provider' } }); - expect(store.dispatch).toHaveBeenCalledWith(new GetCollectionProvider('my-provider')); - }); + it('should have collection details as null', () => { + expect(component.collectionDetails()).toBeNull(); + }); - it('should open help dialog with expected header', () => { - setup(); - (store.dispatch as Mock).mockClear(); - component.openHelpDialog(); - expect(customDialogMock.open).toHaveBeenCalledWith(CollectionsHelpDialogComponent, { - header: 'collections.helpDialog.header', + it('should have selected filters', () => { + expect(component.selectedFilters()).toEqual({}); }); - }); - it('should dispatch search and page when search is triggered in legacy mode', () => { - setup(); - (store.dispatch as Mock).mockClear(); - component.onSearchTriggered('query'); - expect(store.dispatch).toHaveBeenCalledWith(new SetSearchValue('query')); - expect(store.dispatch).toHaveBeenCalledWith(new SetPageNumber('1')); - }); + it('should have sort by value', () => { + expect(component.sortBy()).toBe('date'); + }); - it('should not dispatch search actions when search is triggered in cedar mode', () => { - setup({ useCedarEnvironment: true }); - (store.dispatch as Mock).mockClear(); - component.onSearchTriggered('query'); - expect(store.dispatch).not.toHaveBeenCalledWith(new SetSearchValue('query')); - expect(store.dispatch).not.toHaveBeenCalledWith(new SetPageNumber('1')); - }); + it('should have search text', () => { + expect(component.searchText()).toBe(''); + }); - it('should call query sync initialize and sync when legacy mode store fields are ready', () => { - setup({ - selectorOverrides: [ - { selector: CollectionsSelectors.getCollectionProvider, value: createMockCollectionProvider() }, - { selector: CollectionsSelectors.getCollectionDetails, value: mockCollectionDetails }, - ], + it('should have page number', () => { + expect(component.pageNumber()).toBe('1'); }); - expect(querySyncMock.initializeFromUrl).toHaveBeenCalled(); - expect(querySyncMock.syncStoreToUrl).toHaveBeenCalledWith('', '', MOCK_COLLECTIONS_EMPTY_FILTERS, '1'); - }); - it('should dispatch search collection submissions when legacy prerequisites are met', () => { - setup({ - selectorOverrides: [ - { selector: CollectionsSelectors.getCollectionProvider, value: createMockCollectionProvider() }, - { selector: CollectionsSelectors.getCollectionDetails, value: mockCollectionDetails }, - ], + it('should have loading state', () => { + expect(component.isProviderLoading()).toBe(false); }); - expect(store.dispatch).toHaveBeenCalledWith(new SearchCollectionSubmissions(PROVIDER_ID, '', {}, '1', '')); - }); - it('should apply branding when collection provider exposes brand', () => { - const brand = { - id: 'b1', - name: 'B', - heroLogoImageUrl: 'https://x/h.png', - heroBackgroundImageUrl: 'https://x/hb.png', - topNavLogoImageUrl: 'https://x/n.png', - primaryColor: '#111111', - secondaryColor: '#222222', - backgroundColor: '#333333', - }; - setup({ - selectorOverrides: [ - { - selector: CollectionsSelectors.getCollectionProvider, - value: createMockCollectionProvider({ brand }), - }, - ], + it('should compute primary collection id', () => { + expect(component.primaryCollectionId()).toBe('collection-1'); }); - expect(brandServiceMock.applyBranding).toHaveBeenCalledWith(brand); - expect(headerStyleServiceMock.applyHeaderStyles).toHaveBeenCalledWith('#222222', '#333333'); - }); - it('should dispatch GetCollectionDetails when primary collection id is available', () => { - setup({ - selectorOverrides: [ - { selector: CollectionsSelectors.getCollectionProvider, value: createMockCollectionProvider() }, - ], + it('should handle search control value changes', () => { + component.searchControl.setValue('new search value'); + expect(component.searchControl.value).toBe('new search value'); }); - expect(store.dispatch).toHaveBeenCalledWith(new GetCollectionDetails('col-1')); - }); - it('should dispatch cedar default filters and extra filters when provider and template load', () => { - const provider = createMockCollectionProvider({ - requiredMetadataTemplate: CEDAR_METADATA_DATA_TEMPLATE_JSON_API_MOCK, + it('should not initialize default search filters', () => { + expect(component.defaultSearchFiltersInitialized()).toBe(false); }); - setup({ - useCedarEnvironment: true, - selectorOverrides: [{ selector: CollectionsSelectors.getCollectionProvider, value: provider }], + + 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(); }); - expect(store.dispatch).toHaveBeenCalledWith( - new SetDefaultFilterValue('isContainedBy', 'https://api.test.osf.io/v2/collections/col-1/') - ); - expect(store.dispatch).toHaveBeenCalledWith( - new SetExtraFilters([ - { - key: 'Project Name', - label: 'Project Name', - operator: FilterOperatorOption.AnyOf, - }, - ]) - ); - }); - it('should dispatch ResetSearchState on destroy in cedar mode', () => { - setup({ useCedarEnvironment: true }); - (store.dispatch as Mock).mockClear(); - fixture.destroy(); - expect(store.dispatch).toHaveBeenCalledWith(expect.any(ResetSearchState)); - }); + it('should dispatch setSearchValue and setPageNumber on search triggered', () => { + const { component: localComponent, store: localStore } = setup(); + (localStore.dispatch as Mock).mockClear(); - it('should reset branding and header on destroy in browser', () => { - setup(); - fixture.destroy(); - expect(headerStyleServiceMock.resetToDefaults).toHaveBeenCalled(); - expect(brandServiceMock.resetBranding).toHaveBeenCalled(); - }); + localComponent.onSearchTriggered('my query'); - it('should not dispatch clear actions or reset services on destroy when not in browser', () => { - setup({ platformId: 'server' }); - (store.dispatch as Mock).mockClear(); - brandServiceMock.resetBranding.mockClear(); - headerStyleServiceMock.resetToDefaults.mockClear(); - fixture.destroy(); - expect(store.dispatch).not.toHaveBeenCalled(); - expect(brandServiceMock.resetBranding).not.toHaveBeenCalled(); - expect(headerStyleServiceMock.resetToDefaults).not.toHaveBeenCalled(); + const calls = (localStore.dispatch as Mock).mock.calls.flat(); + expect(calls.some((c: unknown) => c instanceof SetDefaultFilterValue)).toBe(false); + }); }); - it('should debounce search control changes and dispatch trimmed search value', () => { - vi.useFakeTimers(); - try { - setup({ - selectorOverrides: [ - { selector: CollectionsSelectors.getCollectionProvider, value: createMockCollectionProvider() }, - { selector: CollectionsSelectors.getCollectionDetails, value: mockCollectionDetails }, - ], + 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); + }); + + it('should dispatch SetDefaultFilterValue with collection IRI', () => { + const { store } = setup({ collectionSubmissionWithCedar: true }); + const dispatched = (store.dispatch as Mock).mock.calls.flat(); + const setDefaultFilter = dispatched.find( + (c: unknown) => c instanceof SetDefaultFilterValue + ) as SetDefaultFilterValue; + + expect(setDefaultFilter).toBeDefined(); + expect(setDefaultFilter.filterKey).toBe('isContainedBy'); + expect(setDefaultFilter.value).toBe('http://localhost:8000/v2/collections/collection-1/'); + }); + + it('should 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'); + }); + + 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.searchControl.setValue(' trimmed '); - vi.advanceTimersByTime(300); - expect(store.dispatch).toHaveBeenCalledWith(new SetSearchValue('trimmed')); - } finally { - vi.useRealTimers(); - } + + 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 0455286aa..af6994b7e 100644 --- a/src/app/features/collections/components/collections-discover/collections-discover.component.ts +++ b/src/app/features/collections/components/collections-discover/collections-discover.component.ts @@ -164,10 +164,12 @@ export class CollectionsDiscoverComponent { private setupShareTroveSearchEffect(): void { effect(() => { const provider = this.collectionProvider(); + const collectionId = this.primaryCollectionId(); - if (!provider || !provider.iri || this.defaultSearchFiltersInitialized()) return; + if (!provider || !collectionId || this.defaultSearchFiltersInitialized()) return; - this.actions.setDefaultFilterValue('isContainedBy', provider.iri); + const collectionIri = `${this.environment.apiDomainUrl}/v2/collections/${collectionId}/`; + this.actions.setDefaultFilterValue('isContainedBy', collectionIri); if (provider.requiredMetadataTemplate?.attributes?.template) { const extraFilters = CedarTemplateFilterMapper.fromTemplate( diff --git a/src/app/features/collections/store/add-to-collection/add-to-collection.state.ts b/src/app/features/collections/store/add-to-collection/add-to-collection.state.ts index 718041d1e..04a1848c0 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({ - currentProjectSubmission: { - ...state.currentProjectSubmission, + collectionLicenses: { + ...state.collectionLicenses, isLoading: true, }, }); diff --git a/src/app/features/metadata/components/cedar-template-form/cedar-template-form.component.ts b/src/app/features/metadata/components/cedar-template-form/cedar-template-form.component.ts index a128bab01..032e378f3 100644 --- a/src/app/features/metadata/components/cedar-template-form/cedar-template-form.component.ts +++ b/src/app/features/metadata/components/cedar-template-form/cedar-template-form.component.ts @@ -176,10 +176,7 @@ export class CedarTemplateFormComponent { onSubmit() { const editor = this.cedarEditor()?.nativeElement; if (editor && typeof editor.currentMetadata !== 'undefined') { - const cleanedData = CedarMetadataHelper.cleanMetadataForSubmission( - editor.currentMetadata as Record - ); - const finalData = { data: cleanedData, id: this.template().id, isPublished: this.isValid }; + const finalData = { data: editor.currentMetadata, id: this.template().id, isPublished: this.isValid }; this.formData.set(finalData); this.emitData.emit(finalData as CedarRecordDataBinding); } diff --git a/src/app/features/metadata/helpers/cedar-metadata.helper.spec.ts b/src/app/features/metadata/helpers/cedar-metadata.helper.spec.ts deleted file mode 100644 index d5739c98f..000000000 --- a/src/app/features/metadata/helpers/cedar-metadata.helper.spec.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { CedarTemplate } from '../models'; - -import { CedarMetadataHelper } from './cedar-metadata.helper'; - -const MOCK_TEMPLATE: CedarTemplate = { - '@id': 'https://repo.metadatacenter.org/templates/test-id', - '@type': 'https://schema.metadatacenter.org/core/Template', - type: 'object', - title: 'Test Template', - description: 'Test', - $schema: 'http://json-schema.org/draft-04/schema#', - '@context': { - pav: 'http://purl.org/pav/', - xsd: 'http://www.w3.org/2001/XMLSchema#', - bibo: 'http://purl.org/ontology/bibo/', - oslc: 'http://open-services.net/ns/core#', - schema: 'http://schema.org/', - 'schema:name': { '@type': 'xsd:string' }, - 'pav:createdBy': { '@type': '@id' }, - 'pav:createdOn': { '@type': 'xsd:dateTime' }, - 'oslc:modifiedBy': { '@type': '@id' }, - 'pav:lastUpdatedOn': { '@type': 'xsd:dateTime' }, - 'schema:description': { '@type': 'xsd:string' }, - }, - required: [], - properties: {}, - _ui: { order: [], propertyLabels: {}, propertyDescriptions: {} }, -}; - -describe('CedarMetadataHelper', () => { - describe('ensureProperStructure', () => { - it('should return an empty array for non-array input', () => { - expect(CedarMetadataHelper.ensureProperStructure(null)).toEqual([]); - expect(CedarMetadataHelper.ensureProperStructure('string')).toEqual([]); - expect(CedarMetadataHelper.ensureProperStructure({})).toEqual([]); - }); - - it('should normalize array items to have @id, @type, rdfs:label', () => { - const input = [{ '@id': 'id1', '@type': 'type1', 'rdfs:label': 'label1' }]; - expect(CedarMetadataHelper.ensureProperStructure(input)).toEqual([ - { '@id': 'id1', '@type': 'type1', 'rdfs:label': 'label1' }, - ]); - }); - - it('should fill missing properties with defaults', () => { - const input = [{}]; - expect(CedarMetadataHelper.ensureProperStructure(input)).toEqual([ - { '@id': '', '@type': '', 'rdfs:label': null }, - ]); - }); - }); - - describe('buildCedarSystemMetadata', () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2025-01-15T10:00:00.000Z')); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('should set @id to empty string', () => { - const result = CedarMetadataHelper.buildCedarSystemMetadata(MOCK_TEMPLATE); - expect(result['@id']).toBe(''); - }); - - it('should set schema:isBasedOn to the template @id', () => { - const result = CedarMetadataHelper.buildCedarSystemMetadata(MOCK_TEMPLATE); - expect(result['schema:isBasedOn']).toBe('https://repo.metadatacenter.org/templates/test-id'); - }); - - it('should set schema:name and schema:description to empty strings', () => { - const result = CedarMetadataHelper.buildCedarSystemMetadata(MOCK_TEMPLATE); - expect(result['schema:name']).toBe(''); - expect(result['schema:description']).toBe(''); - }); - - it('should set pav:createdBy and oslc:modifiedBy to empty strings', () => { - const result = CedarMetadataHelper.buildCedarSystemMetadata(MOCK_TEMPLATE); - expect(result['pav:createdBy']).toBe(''); - expect(result['oslc:modifiedBy']).toBe(''); - }); - - it('should set pav:createdOn and pav:lastUpdatedOn to the current timestamp', () => { - const result = CedarMetadataHelper.buildCedarSystemMetadata(MOCK_TEMPLATE); - expect(result['pav:createdOn']).toBe('2025-01-15T10:00:00.000Z'); - expect(result['pav:lastUpdatedOn']).toBe('2025-01-15T10:00:00.000Z'); - }); - - it('should copy @context from the template', () => { - const result = CedarMetadataHelper.buildCedarSystemMetadata(MOCK_TEMPLATE); - expect(result['@context']).toEqual(MOCK_TEMPLATE['@context']); - }); - - it('should use empty object for @context when template has none', () => { - const templateWithoutContext = { ...MOCK_TEMPLATE, '@context': undefined } as unknown as CedarTemplate; - const result = CedarMetadataHelper.buildCedarSystemMetadata(templateWithoutContext); - expect(result['@context']).toEqual({}); - }); - - it('should use empty string for schema:isBasedOn when template @id is missing', () => { - const templateWithoutId = { ...MOCK_TEMPLATE, '@id': undefined } as unknown as CedarTemplate; - const result = CedarMetadataHelper.buildCedarSystemMetadata(templateWithoutId); - expect(result['schema:isBasedOn']).toBe(''); - }); - }); - - describe('buildEmptyMetadata', () => { - it('should return an object with @context and LDbase-specific empty arrays', () => { - const result = CedarMetadataHelper.buildEmptyMetadata(); - expect(result['@context']).toEqual({}); - expect(result['Constructs']).toEqual([]); - expect(result['Assessments']).toEqual([]); - }); - }); - - describe('buildStructuredMetadata', () => { - it('should return metadata as-is for keys not in the fix list', () => { - const metadata = { customField: 'value' }; - expect(CedarMetadataHelper.buildStructuredMetadata(metadata)).toEqual({ customField: 'value' }); - }); - - it('should normalize array fields in the fix list', () => { - const metadata = { Constructs: [{ '@id': 'id1' }] }; - const result = CedarMetadataHelper.buildStructuredMetadata(metadata); - expect(result['Constructs']).toEqual([{ '@id': 'id1', '@type': '', 'rdfs:label': null }]); - }); - }); - - describe('cleanMetadataForSubmission', () => { - it('should pass through non-UUID top-level keys unchanged', () => { - const metadata = { '@id': '', 'schema:name': '', 'School Type': { '@value': 'High School' } }; - expect(CedarMetadataHelper.cleanMetadataForSubmission(metadata)).toEqual(metadata); - }); - - it('should remove UUID-format top-level keys', () => { - const metadata = { - '@id': '', - '052a3bf4-2003-42e4-bb38-a63e5e0fc0d3': { '@id': 'https://example.com' }, - 'School Type': { '@value': 'High School' }, - }; - const result = CedarMetadataHelper.cleanMetadataForSubmission(metadata); - expect(result['052a3bf4-2003-42e4-bb38-a63e5e0fc0d3']).toBeUndefined(); - expect(result['@id']).toBe(''); - expect(result['School Type']).toEqual({ '@value': 'High School' }); - }); - - it('should remove UUID-format keys from @context', () => { - const metadata = { - '@context': { - pav: 'http://purl.org/pav/', - 'schema:name': { '@type': 'xsd:string' }, - '052a3bf4-2003-42e4-bb38-a63e5e0fc0d3': 'https://repo.metadatacenter.org/template-fields/3de6ff2c', - 'School Type': 'https://schema.metadatacenter.org/properties/abc', - }, - '@id': '', - }; - const result = CedarMetadataHelper.cleanMetadataForSubmission(metadata); - const ctx = result['@context'] as Record; - expect(ctx['052a3bf4-2003-42e4-bb38-a63e5e0fc0d3']).toBeUndefined(); - expect(ctx['pav']).toBe('http://purl.org/pav/'); - expect(ctx['School Type']).toBe('https://schema.metadatacenter.org/properties/abc'); - }); - - it('should handle missing or null @context gracefully', () => { - const metadata = { '@id': '', 'schema:name': '' }; - expect(() => CedarMetadataHelper.cleanMetadataForSubmission(metadata)).not.toThrow(); - }); - }); -}); diff --git a/src/app/features/metadata/helpers/cedar-metadata.helper.ts b/src/app/features/metadata/helpers/cedar-metadata.helper.ts index b5bce0cd4..9ee0ecc35 100644 --- a/src/app/features/metadata/helpers/cedar-metadata.helper.ts +++ b/src/app/features/metadata/helpers/cedar-metadata.helper.ts @@ -1,21 +1,4 @@ -import { CedarTemplate } from '../models'; - export class CedarMetadataHelper { - static buildCedarSystemMetadata(template: CedarTemplate): Record { - const now = new Date().toISOString(); - return { - '@id': '', - '@context': template['@context'] ?? {}, - 'schema:isBasedOn': template['@id'] ?? '', - 'schema:name': '', - 'schema:description': '', - 'pav:createdBy': '', - 'oslc:modifiedBy': '', - 'pav:createdOn': now, - 'pav:lastUpdatedOn': now, - }; - } - static ensureProperStructure(items: unknown): Record[] { if (!Array.isArray(items)) return []; @@ -67,22 +50,4 @@ export class CedarMetadataHelper { LDbaseInvestigatorORCID: this.ensureProperStructure([]), }; } - - static cleanMetadataForSubmission(metadata: Record): Record { - const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - const cleaned: Record = {}; - - for (const [key, value] of Object.entries(metadata)) { - if (uuidRegex.test(key)) continue; - if (key === '@context' && value && typeof value === 'object') { - cleaned[key] = Object.fromEntries( - Object.entries(value as Record).filter(([k]) => !uuidRegex.test(k)) - ); - } else { - cleaned[key] = value; - } - } - - return cleaned; - } } diff --git a/src/app/features/moderation/components/collection-submission-item/collection-submission-item.component.spec.ts b/src/app/features/moderation/components/collection-submission-item/collection-submission-item.component.spec.ts index 847f824d9..612a93311 100644 --- a/src/app/features/moderation/components/collection-submission-item/collection-submission-item.component.spec.ts +++ b/src/app/features/moderation/components/collection-submission-item/collection-submission-item.component.spec.ts @@ -8,7 +8,6 @@ import { CollectionSubmissionWithGuid } from '@osf/shared/models/collections/col import { CollectionsSelectors } from '@osf/shared/stores/collections'; import { DateAgoPipe } from '@shared/pipes/date-ago.pipe'; -import { MOCK_CONTRIBUTOR } from '@testing/mocks/contributors.mock'; import { MOCK_COLLECTION_SUBMISSION_WITH_GUID } from '@testing/mocks/submission.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; @@ -143,59 +142,4 @@ describe('CollectionSubmissionItemComponent', () => { const currentAction = component.currentReviewAction(); expect(currentAction).toBeNull(); }); - - it('should open a new tab with serialized URL on handleNavigation', () => { - const windowOpenSpy = vi.spyOn(window, 'open').mockReturnValue(null); - fixture.componentRef.setInput('submission', mockSubmission); - fixture.detectChanges(); - - component.handleNavigation(); - - expect(mockRouter.createUrlTree).toHaveBeenCalledWith( - ['../', mockSubmission.nodeId], - expect.objectContaining({ queryParams: { status: 'pending', mode: 'moderation' } }) - ); - expect(windowOpenSpy).toHaveBeenCalledWith('/', '_blank'); - }); - - it('should emit loadContributors on handleOpen', () => { - fixture.componentRef.setInput('submission', mockSubmission); - fixture.detectChanges(); - - const outputSpy = vi.fn(); - component.loadContributors.subscribe(outputSpy); - - component.handleOpen(); - - expect(outputSpy).toHaveBeenCalled(); - }); - - it('should return true for hasMoreContributors when loaded count is less than total', () => { - fixture.componentRef.setInput('submission', { - ...mockSubmission, - contributors: [MOCK_CONTRIBUTOR], - totalContributors: 3, - }); - fixture.detectChanges(); - - expect(component.hasMoreContributors()).toBe(true); - }); - - it('should return false for hasMoreContributors when all contributors are loaded', () => { - fixture.componentRef.setInput('submission', { - ...mockSubmission, - contributors: [MOCK_CONTRIBUTOR], - totalContributors: 1, - }); - fixture.detectChanges(); - - expect(component.hasMoreContributors()).toBe(false); - }); - - it('should return false for hasMoreContributors when contributors are not set', () => { - fixture.componentRef.setInput('submission', mockSubmission); - fixture.detectChanges(); - - expect(component.hasMoreContributors()).toBe(false); - }); }); diff --git a/src/app/shared/mappers/collections/collections.mapper.ts b/src/app/shared/mappers/collections/collections.mapper.ts index d18c2ee96..cd7711c26 100644 --- a/src/app/shared/mappers/collections/collections.mapper.ts +++ b/src/app/shared/mappers/collections/collections.mapper.ts @@ -31,7 +31,6 @@ export class CollectionsMapper { return { id: response.id, type: response.type, - iri: response.links.iri, name: replaceBadEncodedChars(response.attributes.name), description: replaceBadEncodedChars(response.attributes.description), advisoryBoard: response.attributes.advisory_board, @@ -72,7 +71,7 @@ export class CollectionsMapper { backgroundColor: response.embeds.brand.data.attributes.background_color, } : null, - requiredMetadataTemplate: null, + requiredMetadataTemplate: response.embeds.required_metadata_template?.data ?? null, }; } diff --git a/src/app/shared/models/collections/collections-json-api.model.ts b/src/app/shared/models/collections/collections-json-api.model.ts index fc6ce11b3..9dce2537f 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,15 +10,14 @@ import { UserDataErrorResponseJsonApi } from '../user/user-json-api.model'; export interface CollectionProviderResponseJsonApi { id: string; type: string; - links: { - iri: string; - self: string; - }; attributes: CollectionsProviderAttributesJsonApi; embeds: { brand: { data?: BrandDataJsonApi; }; + required_metadata_template?: { + data?: CedarMetadataDataTemplateJsonApi | null; + }; }; relationships: { primary_collection: { @@ -26,12 +26,6 @@ export interface CollectionProviderResponseJsonApi { type: string; }; }; - required_metadata_template?: { - data?: { - id: string; - type: string; - } | null; - }; }; } diff --git a/src/app/shared/models/collections/collections.model.ts b/src/app/shared/models/collections/collections.model.ts index 71c197222..ebecbbe80 100644 --- a/src/app/shared/models/collections/collections.model.ts +++ b/src/app/shared/models/collections/collections.model.ts @@ -8,7 +8,6 @@ import { ProjectModel } from '../projects/projects.model'; import { BaseProviderModel } from '../provider/provider.model'; export interface CollectionProvider extends BaseProviderModel { - iri?: string; assets: { style?: string; squareColorTransparent?: string; diff --git a/src/app/shared/services/collections.service.ts b/src/app/shared/services/collections.service.ts index ba97b566e..2f2bc8256 100644 --- a/src/app/shared/services/collections.service.ts +++ b/src/app/shared/services/collections.service.ts @@ -41,7 +41,6 @@ import { ReviewActionPayloadJsonApi } from '../models/review-action/review-actio import { SetTotalSubmissions } from '../stores/collections/collections.actions'; import { JsonApiService } from './json-api.service'; -import { MetadataService } from './metadata.service'; @Injectable({ providedIn: 'root', @@ -49,7 +48,6 @@ import { MetadataService } from './metadata.service'; export class CollectionsService { private readonly jsonApiService = inject(JsonApiService); private readonly environment = inject(ENVIRONMENT); - private readonly metadataService = inject(MetadataService); get apiUrl() { return `${this.environment.apiDomainUrl}/v2`; @@ -58,22 +56,11 @@ 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,required_metadata_template`; - return this.jsonApiService.get>(url).pipe( - switchMap((response) => { - const provider = CollectionsMapper.fromGetCollectionProviderResponse(response.data); - const templateId = response.data.relationships.required_metadata_template?.data?.id; - - if (!templateId) { - return of(provider); - } - - return this.metadataService - .getCedarMetadataTemplateDetail(templateId) - .pipe(map((template) => ({ ...provider, requiredMetadataTemplate: template }))); - }) - ); + return this.jsonApiService + .get>(url) + .pipe(map((response) => CollectionsMapper.fromGetCollectionProviderResponse(response.data))); } getCollectionDetails(collectionId: string): Observable { diff --git a/src/app/shared/services/metadata.service.ts b/src/app/shared/services/metadata.service.ts index 6488cf11d..82c1bd357 100644 --- a/src/app/shared/services/metadata.service.ts +++ b/src/app/shared/services/metadata.service.ts @@ -6,7 +6,6 @@ import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { CedarRecordsMapper, MetadataMapper, RorMapper } from '@osf/features/metadata/mappers'; import { - CedarMetadataDataTemplateJsonApi, CedarMetadataRecord, CedarMetadataRecordJsonApi, CedarMetadataTemplateJsonApi, @@ -22,7 +21,6 @@ import { } from '@osf/features/metadata/models'; import { ResourceType } from '../enums/resource-type.enum'; -import { JsonApiResponse } from '../models/common/json-api.model'; import { IdentifierModel } from '../models/identifiers/identifier.model'; import { LicenseOptions } from '../models/license/license.model'; import { BaseNodeAttributesJsonApi } from '../models/nodes/base-node-attributes-json-api.model'; @@ -104,14 +102,6 @@ export class MetadataService { ); } - getCedarMetadataTemplateDetail(templateId: string): Observable { - return this.jsonApiService - .get< - JsonApiResponse - >(`${this.apiDomainUrl}/_/cedar_metadata_templates/${templateId}/`) - .pipe(map((response) => response.data)); - } - getMetadataCedarRecords( resourceId: string, resourceType: ResourceType, diff --git a/src/app/shared/stores/global-search/global-search.state.ts b/src/app/shared/stores/global-search/global-search.state.ts index bb94a2461..b20d061b4 100644 --- a/src/app/shared/stores/global-search/global-search.state.ts +++ b/src/app/shared/stores/global-search/global-search.state.ts @@ -276,15 +276,8 @@ export class GlobalSearchState { private updateResourcesState(ctx: StateContext, response: ResourcesData) { const { extraFilters } = ctx.getState(); - const seenKeys = new Set(response.filters.map((f) => f.key)); - const merged = [ - ...response.filters, - ...extraFilters.filter((f) => { - if (seenKeys.has(f.key)) return false; - seenKeys.add(f.key); - return true; - }), - ]; + 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 },