From b86b57e8d4956542ef147263d49107dceeb1d0de Mon Sep 17 00:00:00 2001 From: Yuhuai Liu Date: Fri, 22 May 2026 01:01:43 -0400 Subject: [PATCH 1/4] Revert "Merge pull request #988 from Vlad0n20/feat/ENG-9827" This reverts commit d198ac415267db836e78a0f76902b5564326e35a, reversing changes made to d67e972327480aa9bffe0ab9af2a83275485c9de. --- .../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 }, From 323a6a13aef0f20a00ab352e23062eaa1800d2e3 Mon Sep 17 00:00:00 2001 From: Yuhuai Liu Date: Tue, 26 May 2026 22:42:58 -0400 Subject: [PATCH 2/4] feat(es2): Add waffle flags; revert changes and use cedar editor --- .../add-to-collection.component.ts | 6 +++- .../collections-discover.component.html | 2 +- .../collections-discover.component.spec.ts | 9 +++-- .../collections-discover.component.ts | 34 +++++++++++-------- .../features/metadata/metadata.component.html | 2 +- .../features/metadata/metadata.component.ts | 7 +++- .../shared/constants/feature-flags.const.ts | 1 + .../shared/services/collections.service.ts | 2 +- 8 files changed, 41 insertions(+), 22 deletions(-) create mode 100644 src/app/shared/constants/feature-flags.const.ts diff --git a/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts b/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts index c90a8cee2..8f1cbb670 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 @@ -43,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'; @@ -105,6 +106,7 @@ export class AddToCollectionComponent implements CanDeactivateComponent { requiredMetadataTemplate = select(CollectionsSelectors.getRequiredMetadataTemplate); selectedProject = select(ProjectsSelectors.getSelectedProject); currentUser = select(UserSelectors.getCurrentUser); + activeFlags = select(UserSelectors.getActiveFlags); currentCollectionSubmission = select(AddToCollectionSelectors.getCurrentCollectionSubmission); cedarRecords = select(MetadataSelectors.getCedarRecords); @@ -123,7 +125,9 @@ export class AddToCollectionComponent implements CanDeactivateComponent { isCollectionMetadataDisabled = computed( () => !this.selectedProject() || !this.projectMetadataSaved() || !this.projectContributorsSaved() ); - isCedarMode = computed(() => this.environment.collectionSubmissionWithCedar && !!this.requiredMetadataTemplate()); + isCedarMode = computed( + () => this.activeFlags().includes(COLLECTION_SUBMISSION_WITH_CEDAR) && !!this.requiredMetadataTemplate() + ); existingCedarRecord = computed(() => { const records = this.cedarRecords(); const templateId = this.requiredMetadataTemplate()?.id; diff --git a/src/app/features/collections/components/collections-discover/collections-discover.component.html b/src/app/features/collections/components/collections-discover/collections-discover.component.html index d6971525e..43783ee46 100644 --- a/src/app/features/collections/components/collections-discover/collections-discover.component.html +++ b/src/app/features/collections/components/collections-discover/collections-discover.component.html @@ -37,7 +37,7 @@

{{ collectionProvider()?

- @if (useShareTroveSearch) { + @if (useShareTroveSearch()) { @if (defaultSearchFiltersInitialized()) { } diff --git a/src/app/features/collections/components/collections-discover/collections-discover.component.spec.ts b/src/app/features/collections/components/collections-discover/collections-discover.component.spec.ts index 7be0470cd..71fa80452 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 @@ -8,6 +8,7 @@ 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'; @@ -99,6 +100,10 @@ function setup(options: SetupOptions = {}) { { selector: CollectionsSelectors.getSearchText, value: '' }, { selector: CollectionsSelectors.getPageNumber, value: '1' }, { selector: CollectionsSelectors.getCollectionProviderLoading, value: false }, + { + selector: UserSelectors.getActiveFlags, + value: collectionSubmissionWithCedar ? ['collection_submission_with_cedar'] : [], + }, ], }), ], @@ -130,7 +135,7 @@ describe('CollectionsDiscoverComponent', () => { }); it('should set useShareTroveSearch to false', () => { - expect(component.useShareTroveSearch).toBe(false); + expect(component.useShareTroveSearch()).toBe(false); }); it('should initialize with default values', () => { @@ -199,7 +204,7 @@ describe('CollectionsDiscoverComponent', () => { describe('shtrove mode (collectionSubmissionWithCedar = true)', () => { it('should set useShareTroveSearch to true', () => { const { component } = setup({ collectionSubmissionWithCedar: true }); - expect(component.useShareTroveSearch).toBe(true); + expect(component.useShareTroveSearch()).toBe(true); }); it('should initialize default search filters', () => { diff --git a/src/app/features/collections/components/collections-discover/collections-discover.component.ts b/src/app/features/collections/components/collections-discover/collections-discover.component.ts index af6994b7e..a736c0621 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 @@ -22,6 +22,7 @@ import { FormControl } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } 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'; @@ -41,6 +42,7 @@ import { 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'; @@ -78,7 +80,8 @@ export class CollectionsDiscoverComponent { providerId = signal(''); defaultSearchFiltersInitialized = signal(false); - readonly useShareTroveSearch = this.environment.collectionSubmissionWithCedar; + activeFlags = select(UserSelectors.getActiveFlags); + readonly useShareTroveSearch = computed(() => this.activeFlags().includes(COLLECTION_SUBMISSION_WITH_CEDAR)); collectionProvider = select(CollectionsSelectors.getCollectionProvider); collectionDetails = select(CollectionsSelectors.getCollectionDetails); @@ -106,20 +109,16 @@ export class CollectionsDiscoverComponent { constructor() { this.initializeProvider(); this.setupBrandingEffect(); - - if (this.useShareTroveSearch) { - this.setupShareTroveSearchEffect(); - } else { - this.setupCollectionDetailsEffect(); - this.setupUrlSyncEffect(); - this.setupLegacySearchEffect(); - this.setupSearchBinding(); - } + this.setupShareTroveSearchEffect(); + this.setupCollectionDetailsEffect(); + this.setupUrlSyncEffect(); + this.setupLegacySearchEffect(); + this.setupSearchBinding(); this.destroyRef.onDestroy(() => { if (this.isBrowser) { this.actions.clearCollections(); - if (this.useShareTroveSearch) { + if (this.useShareTroveSearch()) { this.actions.resetSearchState(); } this.headerStyleHelper.resetToDefaults(); @@ -133,7 +132,7 @@ export class CollectionsDiscoverComponent { } onSearchTriggered(searchValue: string): void { - if (!this.useShareTroveSearch) { + if (!this.useShareTroveSearch()) { this.actions.setSearchValue(searchValue); this.actions.setPageNumber('1'); } @@ -166,7 +165,7 @@ export class CollectionsDiscoverComponent { const provider = this.collectionProvider(); const collectionId = this.primaryCollectionId(); - if (!provider || !collectionId || this.defaultSearchFiltersInitialized()) return; + if (!this.useShareTroveSearch() || !provider || !collectionId || this.defaultSearchFiltersInitialized()) return; const collectionIri = `${this.environment.apiDomainUrl}/v2/collections/${collectionId}/`; this.actions.setDefaultFilterValue('isContainedBy', collectionIri); @@ -184,6 +183,8 @@ export class CollectionsDiscoverComponent { private setupCollectionDetailsEffect(): void { effect(() => { + if (this.useShareTroveSearch()) return; + const collectionId = this.primaryCollectionId(); if (collectionId) { this.actions.getCollectionDetails(collectionId); @@ -192,9 +193,10 @@ export class CollectionsDiscoverComponent { } private setupUrlSyncEffect(): void { - this.querySyncService.initializeFromUrl(); - effect(() => { + if (this.useShareTroveSearch()) return; + this.querySyncService.initializeFromUrl(); + const searchText = this.searchText(); const sortBy = this.sortBy(); const selectedFilters = this.selectedFilters(); @@ -208,6 +210,8 @@ export class CollectionsDiscoverComponent { private setupLegacySearchEffect(): void { effect(() => { + if (this.useShareTroveSearch()) return; + const searchText = this.searchText(); const sortBy = this.sortBy(); const selectedFilters = this.selectedFilters(); diff --git a/src/app/features/metadata/metadata.component.html b/src/app/features/metadata/metadata.component.html index c40d56a91..f8c7d7140 100644 --- a/src/app/features/metadata/metadata.component.html +++ b/src/app/features/metadata/metadata.component.html @@ -73,7 +73,7 @@ [isProjectSubmissionsLoading]="isProjectSubmissionsLoading()" [cedarRecords]="cedarRecords()" [cedarTemplates]="cedarTemplates()?.data ?? null" - [isCedarMode]="collectionSubmissionWithCedar" + [isCedarMode]="collectionSubmissionWithCedar()" /> }
diff --git a/src/app/features/metadata/metadata.component.ts b/src/app/features/metadata/metadata.component.ts index ef00699e8..06136387d 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,7 +130,10 @@ export class MetadataComponent implements OnInit, OnDestroy { private readonly environment = inject(ENVIRONMENT); private readonly signpostingService = inject(SignpostingService); - readonly collectionSubmissionWithCedar = this.environment.collectionSubmissionWithCedar; + activeFlags = select(UserSelectors.getActiveFlags); + readonly collectionSubmissionWithCedar = computed(() => + this.activeFlags().includes(COLLECTION_SUBMISSION_WITH_CEDAR) + ); private resourceId = ''; 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/services/collections.service.ts b/src/app/shared/services/collections.service.ts index 2f2bc8256..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,required_metadata_template`; + const url = `${this.apiUrl}/providers/collections/${collectionName}/?embed=brand&embed=required_metadata_template`; return this.jsonApiService .get>(url) From 4655dae8b57e50a8c58dcd266c41bc9651881d01 Mon Sep 17 00:00:00 2001 From: Vlad0n20 Date: Fri, 29 May 2026 14:21:06 +0200 Subject: [PATCH 3/4] feat(ENG-9827): fix conflicts --- ...tion-confirmation-dialog.component.spec.ts | 59 ++++++++++++++++++- ...ollection-confirmation-dialog.component.ts | 26 +++++--- .../add-to-collection.component.ts | 19 +++--- ...collection-metadata-step.component.spec.ts | 26 ++++++-- .../collection-metadata-step.component.ts | 1 + .../mappers/collections/collections.mapper.ts | 4 +- .../collection-submission-payload.model.ts | 2 +- 7 files changed, 111 insertions(+), 26 deletions(-) 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..5a7be126c 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,9 @@ 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 { 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'; import { provideOSFCore } from '@testing/osf.testing.provider'; @@ -19,13 +21,25 @@ import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast import { AddToCollectionConfirmationDialogComponent } from './add-to-collection-confirmation-dialog.component'; +const MOCK_CEDAR_DATA = { + data: { '@context': {} }, + id: 'template-1', + isPublished: true, +} as any; + 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?: unknown; + project?: { id: string; isPublic: boolean }; + cedarData?: unknown; + }; + }; beforeEach(() => { toastService = ToastServiceMock.simple(); @@ -33,6 +47,7 @@ describe('AddToCollectionConfirmationDialogComponent', () => { data: { payload: { title: 'Submission' }, project: { id: 'project-1', isPublic: false }, + cedarData: null, }, }; @@ -69,13 +84,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).not.toHaveBeenCalledWith(expect.any(CreateCedarMetadataRecord)); expect(dialogRef.close).toHaveBeenCalledWith(true); expect(toastService.showSuccess).toHaveBeenCalledWith('collections.addToCollection.confirmationDialogToastMessage'); expect(component.isSubmitting()).toBe(false); @@ -92,6 +108,29 @@ describe('AddToCollectionConfirmationDialogComponent', () => { 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({ title: 'Submission' } as any)); + 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({ title: 'Submission' } as any)); + }); + it('should reset submitting state on error', () => { vi.spyOn(store, 'dispatch').mockImplementation((action) => { if (action instanceof CreateCollectionSubmission) { @@ -106,4 +145,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.ts b/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts index 8f1cbb670..bf2c286a4 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 @@ -211,8 +211,8 @@ export class AddToCollectionComponent implements CanDeactivateComponent { const payload = { collectionId: this.primaryCollectionId() || '', projectId: this.selectedProject()?.id || '', - collectionMetadata: this.isCedarMode() ? {} : this.collectionMetadataForm.value || {}, userId: this.currentUser()?.id || '', + collectionMetadata: this.isCedarMode() ? {} : this.collectionMetadataForm.value || {}, }; const isEditMode = this.isEditMode(); @@ -220,10 +220,9 @@ export class AddToCollectionComponent implements CanDeactivateComponent { if (isEditMode) { this.loaderService.show(); - this.actions - .updateCollectionSubmission(payload) + this.saveCedarRecordIfNeeded() .pipe( - switchMap(() => this.saveCedarRecordIfNeeded()), + switchMap(() => this.actions.updateCollectionSubmission(payload)), finalize(() => this.loaderService.hide()), takeUntilDestroyed(this.destroyRef) ) @@ -242,7 +241,11 @@ export class AddToCollectionComponent implements CanDeactivateComponent { .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), @@ -277,13 +280,13 @@ 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) ) diff --git a/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.spec.ts b/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.spec.ts index f6dc67b64..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 @@ -20,7 +20,13 @@ describe('CollectionMetadataStepComponent', () => { let component: CollectionMetadataStepComponent; let fixture: ComponentFixture; - function setup(isCedarMode = false, cedarTemplate: CedarMetadataDataTemplateJsonApi | null = null) { + function setup( + options: { + isCedarMode?: boolean; + cedarTemplate?: CedarMetadataDataTemplateJsonApi | null; + existingCedarRecord?: CedarMetadataRecordData | null; + } = {} + ) { TestBed.configureTestingModule({ imports: [CollectionMetadataStepComponent, MockComponents(StepPanel, Step, StepItem)], providers: [ @@ -43,9 +49,15 @@ 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); + + 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(); @@ -66,6 +78,10 @@ describe('CollectionMetadataStepComponent', () => { expect(component.isCedarMode()).toBe(false); }); + 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); @@ -128,7 +144,7 @@ describe('CollectionMetadataStepComponent', () => { describe('CEDAR mode', () => { beforeEach(() => { - setup(true, MOCK_CEDAR_TEMPLATE); + setup({ isCedarMode: true, cedarTemplate: MOCK_CEDAR_TEMPLATE }); }); it('should initialize in CEDAR mode', () => { diff --git a/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.ts b/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.ts index b4fe45f64..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 @@ -149,6 +149,7 @@ export class CollectionMetadataStepComponent { }; this.collectionMetadataSaved.set(true); + this.metadataSaved.emit(this.collectionMetadataForm()); this.cedarDataSaved.emit(cedarData); this.stepChange.emit(AddToCollectionSteps.Complete); } diff --git a/src/app/shared/mappers/collections/collections.mapper.ts b/src/app/shared/mappers/collections/collections.mapper.ts index cd7711c26..bdb81caf9 100644 --- a/src/app/shared/mappers/collections/collections.mapper.ts +++ b/src/app/shared/mappers/collections/collections.mapper.ts @@ -243,7 +243,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: { @@ -271,7 +271,7 @@ export class CollectionsMapper { } static collectionSubmissionUpdateRequest(payload: CollectionSubmissionPayload) { - const collectionsMetadata = convertToSnakeCase(payload.collectionMetadata); + const collectionsMetadata = payload.collectionMetadata ? convertToSnakeCase(payload.collectionMetadata) : {}; return { data: { 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; } From 7a6dad9da9fa9e855b1932edc5e7f189c203f9d0 Mon Sep 17 00:00:00 2001 From: Vlad0n20 Date: Fri, 29 May 2026 19:40:11 +0200 Subject: [PATCH 4/4] feat(ENG-9827): fix comments --- ...tion-confirmation-dialog.component.spec.ts | 28 +- .../add-to-collection.component.spec.ts | 277 ++++++++++++++---- .../add-to-collection.component.ts | 2 +- .../collections-discover.component.spec.ts | 33 ++- .../collections-discover.component.ts | 8 +- .../add-to-collection.state.ts | 4 +- .../features/metadata/metadata.component.ts | 2 +- .../mappers/collections/collections.mapper.ts | 1 + .../collections/collections-json-api.model.ts | 3 + .../models/collections/collections.model.ts | 1 + 10 files changed, 284 insertions(+), 75 deletions(-) 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 5a7be126c..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,9 +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'; @@ -21,11 +23,11 @@ import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast import { AddToCollectionConfirmationDialogComponent } from './add-to-collection-confirmation-dialog.component'; -const MOCK_CEDAR_DATA = { - data: { '@context': {} }, +const MOCK_CEDAR_DATA: CedarRecordDataBinding = { + data: { '@context': {} } as CedarMetadataAttributes, id: 'template-1', isPublished: true, -} as any; +}; describe('AddToCollectionConfirmationDialogComponent', () => { let component: AddToCollectionConfirmationDialogComponent; @@ -35,17 +37,23 @@ describe('AddToCollectionConfirmationDialogComponent', () => { let toastService: ToastServiceMockType; let dialogConfig: { data: { - payload?: unknown; + payload?: CollectionSubmissionPayload; project?: { id: string; isPublic: boolean }; - cedarData?: unknown; + 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, }, @@ -90,7 +98,7 @@ describe('AddToCollectionConfirmationDialogComponent', () => { 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'); @@ -103,7 +111,7 @@ 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); }); @@ -117,7 +125,7 @@ describe('AddToCollectionConfirmationDialogComponent', () => { expect(store.dispatch).toHaveBeenCalledWith( new CreateCedarMetadataRecord(MOCK_CEDAR_DATA, 'project-1', ResourceType.Project) ); - expect(store.dispatch).toHaveBeenCalledWith(new CreateCollectionSubmission({ title: 'Submission' } as any)); + expect(store.dispatch).toHaveBeenCalledWith(new CreateCollectionSubmission(MOCK_PAYLOAD)); expect(dialogRef.close).toHaveBeenCalledWith(true); }); @@ -128,7 +136,7 @@ describe('AddToCollectionConfirmationDialogComponent', () => { component.handleAddToCollectionConfirm(); expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(CreateCedarMetadataRecord)); - expect(store.dispatch).toHaveBeenCalledWith(new CreateCollectionSubmission({ title: 'Submission' } as any)); + expect(store.dispatch).toHaveBeenCalledWith(new CreateCollectionSubmission(MOCK_PAYLOAD)); }); it('should reset submitting state on error', () => { diff --git a/src/app/features/collections/components/add-to-collection/add-to-collection.component.spec.ts b/src/app/features/collections/components/add-to-collection/add-to-collection.component.spec.ts index b7c9645b7..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'; @@ -13,7 +15,10 @@ 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'; @@ -22,67 +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: CollectionsSelectors.getRequiredMetadataTemplate, value: null }, - { selector: ProjectsSelectors.getSelectedProject, value: MOCK_PROJECT }, - { selector: UserSelectors.getCurrentUser, value: MOCK_USER }, - { selector: MetadataSelectors.getCedarRecords, value: [] }, - ], - }), - ], - }); +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; - fixture = TestBed.createComponent(AddToCollectionComponent); - component = fixture.componentInstance; - fixture.detectChanges(); + 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(); + + 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); @@ -90,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); @@ -99,6 +153,7 @@ describe('AddToCollectionComponent', () => { }); it('should handle step change', () => { + const { component } = setup(); const newStep = AddToCollectionSteps.ProjectMetadata; component.handleChangeStep(newStep); @@ -106,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); @@ -119,6 +176,7 @@ describe('AddToCollectionComponent', () => { }); it('should handle collection metadata saved', () => { + const { component } = setup(); const mockForm = new FormGroup({}); component.handleCollectionMetadataSaved(mockForm); @@ -128,6 +186,7 @@ describe('AddToCollectionComponent', () => { }); it('should handle cedar data saved', () => { + const { component } = setup(); const mockCedarData: CedarRecordDataBinding = { data: {} as CedarRecordDataBinding['data'], id: 'template-123', @@ -141,24 +200,136 @@ describe('AddToCollectionComponent', () => { }); 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 bf2c286a4..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 @@ -293,7 +293,7 @@ export class AddToCollectionComponent implements CanDeactivateComponent { .subscribe({ next: () => { this.toastService.showSuccess('collections.removeDialog.success'); - this.loaderService.show(); + this.loaderService.hide(); this.allowNavigation.set(true); this.router.navigate([projectId, 'overview']); }, 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 71fa80452..c9da867db 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 @@ -29,12 +29,38 @@ import { CollectionsMainContentComponent } from '../collections-main-content/col import { CollectionsDiscoverComponent } from './collections-discover.component'; +const MOCK_COLLECTION_IRI = 'http://localhost:8000/v2/collections/collection-1/'; + const MOCK_COLLECTION_PROVIDER = { ...MOCK_PROVIDER, 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: { @@ -94,7 +120,10 @@ function setup(options: SetupOptions = {}) { provideMockStore({ signals: [ { selector: CollectionsSelectors.getCollectionProvider, value: provider }, - { selector: CollectionsSelectors.getCollectionDetails, value: null }, + { + selector: CollectionsSelectors.getCollectionDetails, + value: collectionSubmissionWithCedar ? MOCK_COLLECTION_DETAILS : null, + }, { selector: CollectionsSelectors.getAllSelectedFilters, value: {} }, { selector: CollectionsSelectors.getSortBy, value: 'date' }, { selector: CollectionsSelectors.getSearchText, value: '' }, @@ -221,7 +250,7 @@ describe('CollectionsDiscoverComponent', () => { expect(setDefaultFilter).toBeDefined(); expect(setDefaultFilter.filterKey).toBe('isContainedBy'); - expect(setDefaultFilter.value).toBe('http://localhost:8000/v2/collections/collection-1/'); + expect(setDefaultFilter.value).toBe(MOCK_COLLECTION_IRI); }); it('should not dispatch SetExtraFilters when provider has no requiredMetadataTemplate', () => { 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 a736c0621..8eefc8d95 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,7 +21,6 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } 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'; @@ -73,7 +72,6 @@ export class CollectionsDiscoverComponent { private brandService = inject(BrandService); private headerStyleHelper = inject(HeaderStyleService); private platformId = inject(PLATFORM_ID); - private environment = inject(ENVIRONMENT); private isBrowser = isPlatformBrowser(this.platformId); searchControl = new FormControl(''); @@ -163,11 +161,9 @@ export class CollectionsDiscoverComponent { private setupShareTroveSearchEffect(): void { effect(() => { const provider = this.collectionProvider(); - const collectionId = this.primaryCollectionId(); - - if (!this.useShareTroveSearch() || !provider || !collectionId || this.defaultSearchFiltersInitialized()) return; + const collectionIri = this.collectionDetails()?.iri; + if (!this.useShareTroveSearch() || !provider || !collectionIri || this.defaultSearchFiltersInitialized()) return; - const collectionIri = `${this.environment.apiDomainUrl}/v2/collections/${collectionId}/`; this.actions.setDefaultFilterValue('isContainedBy', collectionIri); if (provider.requiredMetadataTemplate?.attributes?.template) { 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/metadata.component.ts b/src/app/features/metadata/metadata.component.ts index 06136387d..46893a926 100644 --- a/src/app/features/metadata/metadata.component.ts +++ b/src/app/features/metadata/metadata.component.ts @@ -130,7 +130,7 @@ export class MetadataComponent implements OnInit, OnDestroy { private readonly environment = inject(ENVIRONMENT); private readonly signpostingService = inject(SignpostingService); - activeFlags = select(UserSelectors.getActiveFlags); + private readonly activeFlags = select(UserSelectors.getActiveFlags); readonly collectionSubmissionWithCedar = computed(() => this.activeFlags().includes(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 bdb81caf9..a13c22e91 100644 --- a/src/app/shared/mappers/collections/collections.mapper.ts +++ b/src/app/shared/mappers/collections/collections.mapper.ts @@ -79,6 +79,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, diff --git a/src/app/shared/models/collections/collections-json-api.model.ts b/src/app/shared/models/collections/collections-json-api.model.ts index 9dce2537f..13e031ca0 100644 --- a/src/app/shared/models/collections/collections-json-api.model.ts +++ b/src/app/shared/models/collections/collections-json-api.model.ts @@ -32,6 +32,9 @@ export interface CollectionProviderResponseJsonApi { export interface CollectionDetailsResponseJsonApi { id: string; type: string; + links?: { + iri?: string; + }; attributes: { title: string; date_created: string; diff --git a/src/app/shared/models/collections/collections.model.ts b/src/app/shared/models/collections/collections.model.ts index ebecbbe80..8fee884d5 100644 --- a/src/app/shared/models/collections/collections.model.ts +++ b/src/app/shared/models/collections/collections.model.ts @@ -39,6 +39,7 @@ export interface CollectionFilters { export interface CollectionDetails { id: string; type: string; + iri?: string; title: string; dateCreated: string; dateModified: string;