From 6d97d38c562e9ce0ec8a5f5c7c835617e36de4bd Mon Sep 17 00:00:00 2001 From: Vlad0n20 Date: Tue, 21 Apr 2026 16:56:35 +0300 Subject: [PATCH 01/32] feat(ENG-9817): collection-specific metadata with cedar --- .../add-to-collection.component.html | 4 + .../add-to-collection.component.spec.ts | 17 ++ .../add-to-collection.component.ts | 84 +++++++++- .../collection-metadata-step.component.html | 99 +++++++---- ...collection-metadata-step.component.spec.ts | 148 ++++++++++++++++- .../collection-metadata-step.component.ts | 105 +++++++++++- .../metadata-collection-item.component.html | 10 ++ ...metadata-collection-item.component.spec.ts | 155 ++++++++++++++++-- .../metadata-collection-item.component.ts | 36 +++- .../metadata-collections.component.html | 7 +- .../metadata-collections.component.spec.ts | 117 +++++++++++++ .../metadata-collections.component.ts | 29 +++- .../features/metadata/metadata.component.html | 3 + .../mappers/collections/collections.mapper.ts | 13 +- .../collections/collections-json-api.model.ts | 10 ++ .../models/collections/collections.model.ts | 3 + src/app/shared/models/environment.model.ts | 1 + .../shared/services/collections.service.ts | 2 +- .../collections/collections.selectors.ts | 5 + src/assets/config/template.json | 3 +- src/assets/i18n/en.json | 2 + 21 files changed, 772 insertions(+), 81 deletions(-) diff --git a/src/app/features/collections/components/add-to-collection/add-to-collection.component.html b/src/app/features/collections/components/add-to-collection/add-to-collection.component.html index 41cf077d5..d76299fba 100644 --- a/src/app/features/collections/components/add-to-collection/add-to-collection.component.html +++ b/src/app/features/collections/components/add-to-collection/add-to-collection.component.html @@ -48,7 +48,11 @@

{{ collectionProvider()? [targetStepValue]="AddToCollectionSteps.CollectionMetadata" [isDisabled]="isCollectionMetadataDisabled()" [primaryCollectionId]="primaryCollectionId()" + [isCedarMode]="isCedarMode()" + [cedarTemplate]="requiredMetadataTemplate()" + [existingCedarRecord]="existingCedarRecord()" (metadataSaved)="handleCollectionMetadataSaved($event)" + (cedarDataSaved)="handleCedarDataSaved($event)" (stepChange)="handleChangeStep($event)" /> diff --git a/src/app/features/collections/components/add-to-collection/add-to-collection.component.spec.ts b/src/app/features/collections/components/add-to-collection/add-to-collection.component.spec.ts index 9f49bfde0..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 @@ -10,6 +10,8 @@ import { ProjectContributorsStepComponent } from '@osf/features/collections/comp import { ProjectMetadataStepComponent } from '@osf/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component'; import { SelectProjectStepComponent } from '@osf/features/collections/components/add-to-collection/select-project-step/select-project-step.component'; import { AddToCollectionSteps } from '@osf/features/collections/enums'; +import { CedarRecordDataBinding } from '@osf/features/metadata/models'; +import { MetadataSelectors } from '@osf/features/metadata/store'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ToastService } from '@osf/shared/services/toast.service'; @@ -62,8 +64,10 @@ describe('AddToCollectionComponent', () => { 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: [] }, ], }), ], @@ -123,6 +127,19 @@ describe('AddToCollectionComponent', () => { expect(component.stepperActiveValue()).toBe(AddToCollectionSteps.Complete); }); + it('should handle cedar data saved', () => { + const mockCedarData: CedarRecordDataBinding = { + data: {} as CedarRecordDataBinding['data'], + id: 'template-123', + isPublished: false, + }; + component.handleCedarDataSaved(mockCedarData); + + expect(component.pendingCedarData()).toEqual(mockCedarData); + expect(component.collectionMetadataSaved()).toBe(true); + expect(component.stepperActiveValue()).toBe(AddToCollectionSteps.Complete); + }); + it('should have actions defined', () => { expect(component.actions).toBeDefined(); expect(component.actions.getCollectionProvider).toBeDefined(); diff --git a/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts b/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts index 15e4fbcbb..f8b5d43f2 100644 --- a/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts +++ b/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts @@ -5,7 +5,7 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Stepper } from 'primeng/stepper'; -import { filter, map, Observable, of, switchMap } from 'rxjs'; +import { filter, finalize, map, Observable, of, switchMap } from 'rxjs'; import { isPlatformBrowser } from '@angular/common'; import { @@ -23,9 +23,18 @@ import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { FormGroup } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { ENVIRONMENT } from '@core/provider/environment.provider'; import { UserSelectors } from '@core/store/user'; +import { CedarMetadataRecordData, CedarRecordDataBinding } from '@osf/features/metadata/models'; +import { + CreateCedarMetadataRecord, + GetCedarMetadataRecords, + MetadataSelectors, + UpdateCedarMetadataRecord, +} from '@osf/features/metadata/store'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { CollectionSubmissionReviewState } from '@osf/shared/enums/collection-submission-review-state.enum'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { CanDeactivateComponent } from '@osf/shared/models/can-deactivate.interface'; import { BrandService } from '@osf/shared/services/brand.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; @@ -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,14 +123,24 @@ 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; + }); - actions = createDispatchMap({ + readonly actions = createDispatchMap({ getCollectionProvider: GetCollectionProvider, clearAddToCollectionState: ClearAddToCollectionState, updateCollectionSubmission: UpdateCollectionSubmission, deleteCollectionSubmission: RemoveCollectionSubmission, setSelectedProject: SetSelectedProject, getCurrentCollectionSubmission: GetCurrentCollectionSubmission, + getCedarRecords: GetCedarMetadataRecords, + createCedarRecord: CreateCedarMetadataRecord, + updateCedarRecord: UpdateCedarMetadataRecord, }); showRemoveButton = computed( @@ -133,7 +156,10 @@ export class AddToCollectionComponent implements CanDeactivateComponent { } @HostListener('window:beforeunload', ['$event']) - onBeforeUnload($event: BeforeUnloadEvent): boolean { + onBeforeUnload($event: BeforeUnloadEvent): boolean | undefined { + if (this.allowNavigation() || !this.hasUnsavedChanges()) { + return undefined; + } $event.preventDefault(); return false; } @@ -171,11 +197,17 @@ 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 || '', }; @@ -186,13 +218,20 @@ export class AddToCollectionComponent implements CanDeactivateComponent { this.actions .updateCollectionSubmission(payload) - .pipe(takeUntilDestroyed(this.destroyRef)) + .pipe( + switchMap(() => this.saveCedarRecordIfNeeded()), + finalize(() => this.loaderService.hide()), + takeUntilDestroyed(this.destroyRef) + ) .subscribe({ next: () => { this.toastService.showSuccess('collections.addToCollection.confirmationDialogToastMessage'); this.allowNavigation.set(true); this.router.navigate([this.selectedProject()?.id, 'overview']); }, + error: () => { + this.toastService.showError('collections.addToCollection.updateError'); + }, }); } else { this.customDialogService @@ -203,11 +242,17 @@ export class AddToCollectionComponent implements CanDeactivateComponent { }) .onClose.pipe( filter((res) => !!res), + switchMap(() => this.saveCedarRecordIfNeeded()), takeUntilDestroyed(this.destroyRef) ) - .subscribe(() => { - this.allowNavigation.set(true); - this.router.navigate([this.selectedProject()?.id, 'overview']); + .subscribe({ + next: () => { + this.allowNavigation.set(true); + this.router.navigate([this.selectedProject()?.id, 'overview']); + }, + error: () => { + this.toastService.showError('collections.addToCollection.updateError'); + }, }); } } @@ -248,6 +293,21 @@ export class AddToCollectionComponent implements CanDeactivateComponent { }); } + private saveCedarRecordIfNeeded(): Observable { + const cedarData = this.pendingCedarData(); + const projectId = this.selectedProject()?.id; + const templateId = this.requiredMetadataTemplate()?.id; + if (!this.isCedarMode() || !cedarData || !projectId || !templateId) { + return of(null); + } + + const existing = this.existingCedarRecord(); + if (existing?.id) { + return this.actions.updateCedarRecord(cedarData, existing.id, projectId, ResourceType.Project); + } + return this.actions.createCedarRecord(cedarData, projectId, ResourceType.Project); + } + private initializeProvider(): void { const id = this.route.snapshot.paramMap.get('providerId'); if (!id) { @@ -286,6 +346,14 @@ export class AddToCollectionComponent implements CanDeactivateComponent { this.actions.setSelectedProject(submission.project); } }); + + effect(() => { + const projectId = this.selectedProjectId(); + const isCedar = this.isCedarMode(); + if (isCedar && projectId) { + this.actions.getCedarRecords(projectId, ResourceType.Project); + } + }); } private setupCleanup() { diff --git a/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.html b/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.html index f10094962..0b0cd6498 100644 --- a/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.html +++ b/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.html @@ -11,14 +11,25 @@

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

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

{{ filterEntry.labelKey | translate }}

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

{{ filterEntry.labelKey | translate }}

-

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

-
+

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

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

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

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

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

} - + } @else { +
+ @for (filterEntry of availableFilterEntries(); track filterEntry.key) { +
+ + +
+ } +
-
- - -
+
+ + +
+ } diff --git a/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.spec.ts b/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.spec.ts index 96c1f74cd..441e21cd3 100644 --- a/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.spec.ts +++ b/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.spec.ts @@ -6,6 +6,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormControl, FormGroup, Validators } from '@angular/forms'; import { AddToCollectionSteps } from '@osf/features/collections/enums'; +import { AddToCollectionSelectors } from '@osf/features/collections/store/add-to-collection'; +import { CedarMetadataDataTemplateJsonApi, CedarMetadataRecordData } from '@osf/features/metadata/models'; import { CollectionsSelectors } from '@shared/stores/collections'; import { provideOSFCore } from '@testing/osf.testing.provider'; @@ -13,11 +15,44 @@ import { provideMockStore } from '@testing/providers/store-provider.mock'; import { CollectionMetadataStepComponent } from './collection-metadata-step.component'; -describe.skip('CollectionMetadataStepComponent', () => { +const MOCK_CEDAR_TEMPLATE: CedarMetadataDataTemplateJsonApi = { + id: 'template-1', + type: 'cedar-metadata-templates', + attributes: { + schema_name: 'Test Template', + cedar_id: 'cedar-1', + template: { + '@id': 'https://repo.metadatacenter.org/templates/1', + '@type': 'https://schema.metadatacenter.org/core/Template', + type: 'object', + title: 'Test', + description: 'Test template', + $schema: 'http://json-schema.org/draft-04/schema#', + '@context': { + pav: 'http://purl.org/pav/', + xsd: 'http://www.w3.org/2001/XMLSchema#', + bibo: 'http://purl.org/ontology/bibo/', + oslc: 'http://open-services.net/ns/core#', + schema: 'http://schema.org/', + 'schema:name': { '@type': 'xsd:string' }, + 'pav:createdBy': { '@type': '@id' }, + 'pav:createdOn': { '@type': 'xsd:dateTime' }, + 'oslc:modifiedBy': { '@type': '@id' }, + 'pav:lastUpdatedOn': { '@type': 'xsd:dateTime' }, + 'schema:description': { '@type': 'xsd:string' }, + }, + required: [], + properties: {}, + _ui: { order: [], propertyLabels: {}, propertyDescriptions: {} }, + }, + }, +}; + +describe('CollectionMetadataStepComponent', () => { let component: CollectionMetadataStepComponent; let fixture: ComponentFixture; - beforeEach(() => { + function setup(isCedarMode = false, cedarTemplate: CedarMetadataDataTemplateJsonApi | null = null) { TestBed.configureTestingModule({ imports: [CollectionMetadataStepComponent, MockComponents(StepPanel, Step, StepItem)], providers: [ @@ -27,6 +62,7 @@ describe.skip('CollectionMetadataStepComponent', () => { { selector: CollectionsSelectors.getCollectionProvider, value: null }, { selector: CollectionsSelectors.getCollectionProviderLoading, value: false }, { selector: CollectionsSelectors.getAllFiltersOptions, value: {} }, + { selector: AddToCollectionSelectors.getCurrentCollectionSubmission, value: null }, ], }), ], @@ -39,8 +75,16 @@ describe.skip('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(); + } + + beforeEach(() => { + setup(); }); it('should create', () => { @@ -51,9 +95,10 @@ describe.skip('CollectionMetadataStepComponent', () => { expect(component.stepperActiveValue()).toBe(0); expect(component.targetStepValue()).toBe(1); expect(component.isDisabled()).toBe(false); + expect(component.isCedarMode()).toBe(false); }); - it('should handle save metadata', () => { + it('should handle save metadata in filter mode', () => { const mockForm = new FormGroup({}); component.collectionMetadataForm.set(mockForm); @@ -87,7 +132,7 @@ describe.skip('CollectionMetadataStepComponent', () => { expect(navigateSpy).toHaveBeenCalledWith(component.targetStepValue()); }); - it('should handle discard changes', () => { + it('should handle discard changes in filter mode', () => { const mockForm = new FormGroup({}); component.collectionMetadataForm.set(mockForm); component.collectionMetadataSaved.set(true); @@ -102,11 +147,6 @@ describe.skip('CollectionMetadataStepComponent', () => { expect(component.collectionMetadataSaved()).toBe(false); }); - it('should have actions defined', () => { - expect(component.actions).toBeDefined(); - expect(component.actions.getCollectionDetails).toBeDefined(); - }); - it('should handle different input values', () => { fixture.componentRef.setInput('stepperActiveValue', 2); fixture.componentRef.setInput('targetStepValue', 3); @@ -117,4 +157,94 @@ describe.skip('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 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 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 = jest.spyOn(component.cedarDataSaved, 'emit'); + const stepChangeSpy = jest.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 = jest.spyOn(component.cedarDataSaved, 'emit'); + + component.handleSaveCedarMetadata(); + + expect(cedarDataSavedSpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.ts b/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.ts index acb6a1d0b..e0d57b247 100644 --- a/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.ts +++ b/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.ts @@ -7,13 +7,32 @@ import { Select } from 'primeng/select'; import { Step, StepItem, StepPanel } from 'primeng/stepper'; import { Tooltip } from 'primeng/tooltip'; -import { ChangeDetectionStrategy, Component, computed, effect, input, output, signal } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + computed, + CUSTOM_ELEMENTS_SCHEMA, + effect, + ElementRef, + input, + output, + signal, + ViewChild, + ViewEncapsulation, +} from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { collectionFilterTypes } from '@osf/features/collections/constants'; import { AddToCollectionSteps, CollectionFilterType } from '@osf/features/collections/enums'; import { CollectionFilterEntry } from '@osf/features/collections/models/collection-filter-entry.model'; import { AddToCollectionSelectors } from '@osf/features/collections/store/add-to-collection'; +import { CEDAR_CONFIG, CEDAR_VIEWER_CONFIG } from '@osf/features/metadata/constants'; +import { + CedarEditorElement, + CedarMetadataDataTemplateJsonApi, + CedarMetadataRecordData, + CedarRecordDataBinding, +} from '@osf/features/metadata/models'; import { CollectionSubmissionWithGuid } from '@osf/shared/models/collections/collections.model'; import { CollectionsSelectors, GetCollectionDetails } from '@osf/shared/stores/collections'; @@ -23,6 +42,8 @@ import { CollectionsSelectors, GetCollectionDetails } from '@osf/shared/stores/c templateUrl: './collection-metadata-step.component.html', styleUrl: './collection-metadata-step.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + encapsulation: ViewEncapsulation.None, }) export class CollectionMetadataStepComponent { private readonly filterTypes = collectionFilterTypes; @@ -45,16 +66,27 @@ export class CollectionMetadataStepComponent { targetStepValue = input.required(); isDisabled = input.required(); primaryCollectionId = input(); + isCedarMode = input(false); + cedarTemplate = input(null); + existingCedarRecord = input(null); stepChange = output(); metadataSaved = output(); + cedarDataSaved = output(); collectionMetadataForm = signal(new FormGroup({})); collectionMetadataSaved = signal(false); originalFormValues = signal>({}); formPopulatedFromSubmission = signal(false); + cedarFormData = signal>({}); - actions = createDispatchMap({ getCollectionDetails: GetCollectionDetails }); + cedarConfig = CEDAR_CONFIG; + cedarViewerConfig = CEDAR_VIEWER_CONFIG; + + @ViewChild('cedarEditor') cedarEditor?: ElementRef; + @ViewChild('cedarViewer') cedarViewer?: ElementRef; + + private readonly actions = createDispatchMap({ getCollectionDetails: GetCollectionDetails }); constructor() { this.setupEffects(); @@ -65,6 +97,19 @@ export class CollectionMetadataStepComponent { } handleDiscardChanges() { + if (this.isCedarMode()) { + const record = this.existingCedarRecord(); + this.cedarFormData.set( + record?.attributes?.metadata ? (record.attributes.metadata as Record) : {} + ); + const editor = this.cedarEditor?.nativeElement; + if (editor) { + editor.instanceObject = this.cedarFormData(); + } + this.collectionMetadataSaved.set(false); + return; + } + const form = this.collectionMetadataForm(); const originalValues = this.originalFormValues(); @@ -85,6 +130,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/metadata/components/metadata-collection-item/metadata-collection-item.component.html b/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.html index cf05872c9..d7a384a20 100644 --- a/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.html +++ b/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.html @@ -22,6 +22,16 @@
+@if (showCedarViewer()) { +
+ +
+} + @if (showAttributes()) {
@for (attribute of attributes(); track attribute.key) { diff --git a/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.spec.ts b/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.spec.ts index 106fea114..7855ea8ca 100644 --- a/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.spec.ts +++ b/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.spec.ts @@ -1,6 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; +import { CedarMetadataDataTemplateJsonApi, CedarMetadataRecordData } from '@osf/features/metadata/models'; import { CollectionSubmissionReviewState } from '@osf/shared/enums/collection-submission-review-state.enum'; import { CollectionSubmission } from '@osf/shared/models/collections/collections.model'; @@ -8,28 +9,74 @@ import { provideOSFCore } from '@testing/osf.testing.provider'; import { MetadataCollectionItemComponent } from './metadata-collection-item.component'; +const mockSubmission: CollectionSubmission = { + id: '1', + type: 'collection-submission', + collectionTitle: 'Test Collection', + collectionId: 'collection-123', + reviewsState: CollectionSubmissionReviewState.Pending, + collectedType: 'preprint', + status: 'pending', + volume: '1', + issue: '1', + programArea: 'Science', + schoolType: 'University', + studyDesign: 'Experimental', + dataType: 'Quantitative', + disease: 'Cancer', + gradeLevels: 'Graduate', + requiredMetadataTemplateId: 'template-1', +}; + +const mockCedarTemplate: CedarMetadataDataTemplateJsonApi = { + id: 'template-1', + type: 'cedar-metadata-templates', + attributes: { + schema_name: 'Test Template', + cedar_id: 'cedar-1', + template: { + '@id': 'https://repo.metadatacenter.org/templates/1', + '@type': 'https://schema.metadatacenter.org/core/Template', + type: 'object', + title: 'Test', + description: 'Test template', + $schema: 'http://json-schema.org/draft-04/schema#', + '@context': { + pav: 'http://purl.org/pav/', + xsd: 'http://www.w3.org/2001/XMLSchema#', + bibo: 'http://purl.org/ontology/bibo/', + oslc: 'http://open-services.net/ns/core#', + schema: 'http://schema.org/', + 'schema:name': { '@type': 'xsd:string' }, + 'pav:createdBy': { '@type': '@id' }, + 'pav:createdOn': { '@type': 'xsd:dateTime' }, + 'oslc:modifiedBy': { '@type': '@id' }, + 'pav:lastUpdatedOn': { '@type': 'xsd:dateTime' }, + 'schema:description': { '@type': 'xsd:string' }, + }, + required: [], + properties: {}, + _ui: { order: [], propertyLabels: {}, propertyDescriptions: {} }, + }, + }, +}; + +const mockCedarRecord: CedarMetadataRecordData = { + id: 'record-1', + attributes: { + metadata: { field: 'value' } as CedarMetadataRecordData['attributes']['metadata'], + is_published: true, + }, + relationships: { + template: { data: { type: 'cedar-metadata-templates', id: 'template-1' } }, + target: { data: { type: 'nodes', id: 'node-1' } }, + }, +}; + describe('MetadataCollectionItemComponent', () => { let component: MetadataCollectionItemComponent; let fixture: ComponentFixture; - const mockSubmission: CollectionSubmission = { - id: '1', - type: 'collection-submission', - collectionTitle: 'Test Collection', - collectionId: 'collection-123', - reviewsState: CollectionSubmissionReviewState.Pending, - collectedType: 'preprint', - status: 'pending', - volume: '1', - issue: '1', - programArea: 'Science', - schoolType: 'University', - studyDesign: 'Experimental', - dataType: 'Quantitative', - disease: 'Cancer', - gradeLevels: 'Graduate', - }; - beforeEach(() => { TestBed.configureTestingModule({ imports: [MetadataCollectionItemComponent], @@ -149,4 +196,76 @@ describe('MetadataCollectionItemComponent', () => { const attributesSection = fixture.nativeElement.querySelector('.flex.flex-column.gap-2.mt-2'); expect(attributesSection).toBeFalsy(); }); + + describe('CEDAR mode', () => { + it('should not show cedar viewer when isCedarMode is false', () => { + fixture.componentRef.setInput('isCedarMode', false); + fixture.componentRef.setInput('cedarRecord', mockCedarRecord); + fixture.componentRef.setInput('cedarTemplate', mockCedarTemplate); + fixture.detectChanges(); + + expect(component.showCedarViewer()).toBe(false); + }); + + it('should not show cedar viewer when cedarRecord is null', () => { + fixture.componentRef.setInput('isCedarMode', true); + fixture.componentRef.setInput('cedarRecord', null); + fixture.componentRef.setInput('cedarTemplate', mockCedarTemplate); + fixture.detectChanges(); + + expect(component.showCedarViewer()).toBe(false); + }); + + it('should not show cedar viewer when cedarTemplate is null', () => { + fixture.componentRef.setInput('isCedarMode', true); + fixture.componentRef.setInput('cedarRecord', mockCedarRecord); + fixture.componentRef.setInput('cedarTemplate', null); + fixture.detectChanges(); + + expect(component.showCedarViewer()).toBe(false); + }); + + it('should show cedar viewer when isCedarMode, record, and template are provided', () => { + fixture.componentRef.setInput('isCedarMode', true); + fixture.componentRef.setInput('cedarRecord', mockCedarRecord); + fixture.componentRef.setInput('cedarTemplate', mockCedarTemplate); + fixture.detectChanges(); + + expect(component.showCedarViewer()).toBe(true); + }); + + it('should not show cedar viewer when submission is removed', () => { + fixture.componentRef.setInput('isCedarMode', true); + fixture.componentRef.setInput('cedarRecord', mockCedarRecord); + fixture.componentRef.setInput('cedarTemplate', mockCedarTemplate); + fixture.componentRef.setInput('submission', { + ...mockSubmission, + reviewsState: CollectionSubmissionReviewState.Removed, + }); + fixture.detectChanges(); + + expect(component.showCedarViewer()).toBe(false); + }); + + it('should not show attributes in cedar mode', () => { + fixture.componentRef.setInput('isCedarMode', true); + fixture.detectChanges(); + + expect(component.showAttributes()).toBe(false); + }); + + it('should compute cedarMetadata from record', () => { + fixture.componentRef.setInput('cedarRecord', mockCedarRecord); + fixture.detectChanges(); + + expect(component.cedarMetadata()).toEqual({ field: 'value' }); + }); + + it('should return empty object for cedarMetadata when no record', () => { + fixture.componentRef.setInput('cedarRecord', null); + fixture.detectChanges(); + + expect(component.cedarMetadata()).toEqual({}); + }); + }); }); diff --git a/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.ts b/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.ts index 1c023afd9..c9d700248 100644 --- a/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.ts +++ b/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.ts @@ -3,10 +3,19 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Tag } from 'primeng/tag'; -import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + computed, + CUSTOM_ELEMENTS_SCHEMA, + input, + ViewEncapsulation, +} from '@angular/core'; import { RouterLink } from '@angular/router'; import { collectionFilterNames } from '@osf/features/collections/constants'; +import { CEDAR_VIEWER_CONFIG } from '@osf/features/metadata/constants'; +import { CedarMetadataDataTemplateJsonApi, CedarMetadataRecordData } from '@osf/features/metadata/models'; import { CollectionSubmissionReviewState } from '@osf/shared/enums/collection-submission-review-state.enum'; import { CollectionSubmission } from '@osf/shared/models/collections/collections.model'; import { KeyValueModel } from '@osf/shared/models/common/key-value.model'; @@ -18,11 +27,18 @@ import { CollectionStatusSeverityPipe } from '@osf/shared/pipes/collection-statu templateUrl: './metadata-collection-item.component.html', styleUrl: './metadata-collection-item.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + encapsulation: ViewEncapsulation.None, }) export class MetadataCollectionItemComponent { readonly CollectionSubmissionReviewState = CollectionSubmissionReviewState; submission = input.required(); + isCedarMode = input(false); + cedarRecord = input(null); + cedarTemplate = input(null); + + cedarViewerConfig = CEDAR_VIEWER_CONFIG; showSubmissionButton = computed(() => this.submission().reviewsState === CollectionSubmissionReviewState.Accepted); @@ -32,9 +48,25 @@ export class MetadataCollectionItemComponent { }); showAttributes = computed( - () => this.submission().reviewsState !== CollectionSubmissionReviewState.Removed && !!this.attributes().length + () => + !this.isCedarMode() && + this.submission().reviewsState !== CollectionSubmissionReviewState.Removed && + !!this.attributes().length + ); + + showCedarViewer = computed( + () => + this.isCedarMode() && + !!this.cedarRecord() && + !!this.cedarTemplate()?.attributes?.template && + this.submission().reviewsState !== CollectionSubmissionReviewState.Removed ); + cedarMetadata = computed(() => { + const record = this.cedarRecord(); + return record?.attributes?.metadata ? (record.attributes.metadata as Record) : {}; + }); + attributes = computed(() => { const submission = this.submission(); const attributes: KeyValueModel[] = []; diff --git a/src/app/features/metadata/components/metadata-collections/metadata-collections.component.html b/src/app/features/metadata/components/metadata-collections/metadata-collections.component.html index 2a135bdee..d9d0a0815 100644 --- a/src/app/features/metadata/components/metadata-collections/metadata-collections.component.html +++ b/src/app/features/metadata/components/metadata-collections/metadata-collections.component.html @@ -9,7 +9,12 @@

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

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

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

diff --git a/src/app/features/metadata/components/metadata-collections/metadata-collections.component.spec.ts b/src/app/features/metadata/components/metadata-collections/metadata-collections.component.spec.ts index bd9568d2c..d0a8bf983 100644 --- a/src/app/features/metadata/components/metadata-collections/metadata-collections.component.spec.ts +++ b/src/app/features/metadata/components/metadata-collections/metadata-collections.component.spec.ts @@ -4,12 +4,84 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { ActivatedRoute } from '@angular/router'; +import { CedarMetadataDataTemplateJsonApi, CedarMetadataRecordData } from '@osf/features/metadata/models'; +import { CollectionSubmissionReviewState } from '@osf/shared/enums/collection-submission-review-state.enum'; +import { CollectionSubmission } from '@osf/shared/models/collections/collections.model'; + import { MOCK_PROJECT_COLLECTION_SUBMISSIONS } from '@testing/data/collections/collection-submissions.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; import { MetadataCollectionsComponent } from './metadata-collections.component'; +const mockTemplateId = 'template-1'; + +const mockCedarTemplate: CedarMetadataDataTemplateJsonApi = { + id: mockTemplateId, + type: 'cedar-metadata-templates', + attributes: { + schema_name: 'Test', + cedar_id: 'cedar-1', + template: { + '@id': '', + '@type': '', + type: 'object', + title: 'Test', + description: '', + $schema: '', + '@context': { + pav: '', + xsd: '', + bibo: '', + oslc: '', + schema: '', + '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: {} }, + }, + }, +}; + +const mockCedarRecord: CedarMetadataRecordData = { + id: 'record-1', + attributes: { + metadata: {} as CedarMetadataRecordData['attributes']['metadata'], + is_published: false, + }, + relationships: { + template: { data: { type: 'cedar-metadata-templates', id: mockTemplateId } }, + target: { data: { type: 'nodes', id: 'node-1' } }, + }, +}; + +const mockSubmissionsWithTemplate: CollectionSubmission[] = [ + { + id: '1', + type: 'collection-submissions', + collectionTitle: 'Collection A', + collectionId: 'col1', + reviewsState: CollectionSubmissionReviewState.Accepted, + collectedType: '', + status: 'accepted', + volume: '', + issue: '', + programArea: '', + schoolType: '', + studyDesign: '', + dataType: '', + disease: '', + gradeLevels: '', + requiredMetadataTemplateId: mockTemplateId, + }, +]; + describe('MetadataCollectionsComponent', () => { let component: MetadataCollectionsComponent; let fixture: ComponentFixture; @@ -53,4 +125,49 @@ describe('MetadataCollectionsComponent', () => { const content = fixture.nativeElement.textContent; expect(content).toContain('project.overview.metadata.noCollections'); }); + + it('should default isCedarMode to false', () => { + expect(component.isCedarMode()).toBe(false); + }); + + it('should build cedarRecordByTemplateId map from records', () => { + fixture.componentRef.setInput('cedarRecords', [mockCedarRecord]); + fixture.detectChanges(); + + const map = component.cedarRecordByTemplateId(); + expect(map.get(mockTemplateId)).toEqual(mockCedarRecord); + }); + + it('should build empty cedarRecordByTemplateId map when no records', () => { + fixture.componentRef.setInput('cedarRecords', null); + fixture.detectChanges(); + + expect(component.cedarRecordByTemplateId().size).toBe(0); + }); + + it('should build cedarTemplateById map from templates', () => { + fixture.componentRef.setInput('cedarTemplates', [mockCedarTemplate]); + fixture.detectChanges(); + + const map = component.cedarTemplateById(); + expect(map.get(mockTemplateId)).toEqual(mockCedarTemplate); + }); + + it('should build empty cedarTemplateById map when no templates', () => { + fixture.componentRef.setInput('cedarTemplates', null); + fixture.detectChanges(); + + expect(component.cedarTemplateById().size).toBe(0); + }); + + it('should pass matching cedarRecord to items in cedar mode', () => { + fixture.componentRef.setInput('isCedarMode', true); + fixture.componentRef.setInput('projectSubmissions', mockSubmissionsWithTemplate); + fixture.componentRef.setInput('cedarRecords', [mockCedarRecord]); + fixture.componentRef.setInput('cedarTemplates', [mockCedarTemplate]); + fixture.detectChanges(); + + const items = fixture.debugElement.queryAll(By.css('osf-metadata-collection-item')); + expect(items.length).toBe(1); + }); }); diff --git a/src/app/features/metadata/components/metadata-collections/metadata-collections.component.ts b/src/app/features/metadata/components/metadata-collections/metadata-collections.component.ts index affc90e98..8bade53f8 100644 --- a/src/app/features/metadata/components/metadata-collections/metadata-collections.component.ts +++ b/src/app/features/metadata/components/metadata-collections/metadata-collections.component.ts @@ -3,8 +3,9 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Card } from 'primeng/card'; import { Skeleton } from 'primeng/skeleton'; -import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; +import { CedarMetadataDataTemplateJsonApi, CedarMetadataRecordData } from '@osf/features/metadata/models'; import { CollectionSubmission } from '@osf/shared/models/collections/collections.model'; import { MetadataCollectionItemComponent } from '../metadata-collection-item/metadata-collection-item.component'; @@ -19,4 +20,30 @@ import { MetadataCollectionItemComponent } from '../metadata-collection-item/met export class MetadataCollectionsComponent { projectSubmissions = input(null); isProjectSubmissionsLoading = input(false); + cedarRecords = input(null); + cedarTemplates = input(null); + isCedarMode = input(false); + + cedarRecordByTemplateId = computed(() => { + const records = this.cedarRecords(); + if (!records?.length) return new Map(); + const map = new Map(); + for (const record of records) { + const templateId = record.relationships?.template?.data?.id; + if (templateId) { + map.set(templateId, record); + } + } + return map; + }); + + cedarTemplateById = computed(() => { + const templates = this.cedarTemplates(); + if (!templates?.length) return new Map(); + const map = new Map(); + for (const template of templates) { + map.set(template.id, template); + } + return map; + }); } diff --git a/src/app/features/metadata/metadata.component.html b/src/app/features/metadata/metadata.component.html index f49bd7ff0..54791a2fe 100644 --- a/src/app/features/metadata/metadata.component.html +++ b/src/app/features/metadata/metadata.component.html @@ -71,6 +71,9 @@ }
diff --git a/src/app/shared/mappers/collections/collections.mapper.ts b/src/app/shared/mappers/collections/collections.mapper.ts index 680b34a04..7f8b301bf 100644 --- a/src/app/shared/mappers/collections/collections.mapper.ts +++ b/src/app/shared/mappers/collections/collections.mapper.ts @@ -71,6 +71,7 @@ export class CollectionsMapper { backgroundColor: response.embeds.brand.data.attributes.background_color, } : null, + requiredMetadataTemplate: response.embeds.required_metadata_template?.data ?? null, }; } @@ -116,6 +117,8 @@ export class CollectionsMapper { gradeLevels: submission.attributes.grade_levels, collectionTitle: replaceBadEncodedChars(submission.embeds.collection.data.attributes.title), collectionId: submission.embeds.collection.data.relationships.provider.data.id, + requiredMetadataTemplateId: + submission.embeds.collection.data.relationships.required_metadata_template?.data?.id ?? null, }; } @@ -150,8 +153,8 @@ export class CollectionsMapper { gradeLevels: submission.attributes.grade_levels, creator: creator ? { - id: creator?.id, - fullName: creator?.fullName, + id: creator.id, + fullName: creator.fullName, } : undefined, } as CollectionSubmissionWithGuid; @@ -268,11 +271,15 @@ export class CollectionsMapper { } static collectionSubmissionUpdateRequest(payload: CollectionSubmissionPayload) { + const collectionsMetadata = convertToSnakeCase(payload.collectionMetadata); + return { data: { id: `${payload.projectId}-${payload.collectionId}`, type: 'collection-submissions', - attributes: {}, + attributes: { + ...collectionsMetadata, + }, relationships: {}, }, }; diff --git a/src/app/shared/models/collections/collections-json-api.model.ts b/src/app/shared/models/collections/collections-json-api.model.ts index 2ce9402af..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'; @@ -14,6 +15,9 @@ export interface CollectionProviderResponseJsonApi { brand: { data?: BrandDataJsonApi; }; + required_metadata_template?: { + data?: CedarMetadataDataTemplateJsonApi | null; + }; }; relationships: { primary_collection: { @@ -76,6 +80,12 @@ export interface CollectionSubmissionJsonApi { id: string; }; }; + required_metadata_template?: { + data?: { + id: string; + type: string; + } | null; + }; }; }; }; diff --git a/src/app/shared/models/collections/collections.model.ts b/src/app/shared/models/collections/collections.model.ts index 6b67d7d16..ebecbbe80 100644 --- a/src/app/shared/models/collections/collections.model.ts +++ b/src/app/shared/models/collections/collections.model.ts @@ -1,3 +1,4 @@ +import { CedarMetadataDataTemplateJsonApi } from '@osf/features/metadata/models'; import { CollectionSubmissionReviewAction } from '@osf/features/moderation/models'; import { CollectionSubmissionReviewState } from '@osf/shared/enums/collection-submission-review-state.enum'; @@ -19,6 +20,7 @@ export interface CollectionProvider extends BaseProviderModel { }; brand: BrandModel | null; defaultLicenseId?: string | null; + requiredMetadataTemplate?: CedarMetadataDataTemplateJsonApi | null; } export interface CollectionFilters { @@ -62,6 +64,7 @@ export interface CollectionSubmission { dataType: string; disease: string; gradeLevels: string; + requiredMetadataTemplateId?: string | null; } export interface CollectionSubmissionWithGuid { diff --git a/src/app/shared/models/environment.model.ts b/src/app/shared/models/environment.model.ts index 184fe4ce4..3a688ca4e 100644 --- a/src/app/shared/models/environment.model.ts +++ b/src/app/shared/models/environment.model.ts @@ -65,4 +65,5 @@ export interface EnvironmentModel { */ googleFilePickerAppId: number; throttleToken: string; + collectionSubmissionWithCedar: boolean; } diff --git a/src/app/shared/services/collections.service.ts b/src/app/shared/services/collections.service.ts index 8b13f253a..2fea963f7 100644 --- a/src/app/shared/services/collections.service.ts +++ b/src/app/shared/services/collections.service.ts @@ -56,7 +56,7 @@ export class CollectionsService { private actions = createDispatchMap({ setTotalSubmissions: SetTotalSubmissions }); getCollectionProvider(collectionName: string): Observable { - const url = `${this.apiUrl}/providers/collections/${collectionName}/?embed=brand`; + const url = `${this.apiUrl}/providers/collections/${collectionName}/?embed=brand,required_metadata_template`; return this.jsonApiService .get>(url) diff --git a/src/app/shared/stores/collections/collections.selectors.ts b/src/app/shared/stores/collections/collections.selectors.ts index ca076ada9..22620d7ae 100644 --- a/src/app/shared/stores/collections/collections.selectors.ts +++ b/src/app/shared/stores/collections/collections.selectors.ts @@ -21,6 +21,11 @@ export class CollectionsSelectors { return state.collectionProvider.data; } + @Selector([CollectionsState]) + static getRequiredMetadataTemplate(state: CollectionsStateModel) { + return state.collectionProvider.data?.requiredMetadataTemplate ?? null; + } + @Selector([CollectionsState]) static getCollectionDetails(state: CollectionsStateModel) { return state.collectionDetails.data; diff --git a/src/assets/config/template.json b/src/assets/config/template.json index 826cb39c4..18f954f75 100644 --- a/src/assets/config/template.json +++ b/src/assets/config/template.json @@ -27,5 +27,6 @@ "newRelicLoaderConfigTrustKey": "", "newRelicLoaderConfigAgentID": "", "newRelicLoaderConfigLicenseKey": "", - "newRelicLoaderConfigApplicationID": "" + "newRelicLoaderConfigApplicationID": "", + "collectionSubmissionWithCedar": false } diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index be31ffa9a..02b338530 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1403,6 +1403,7 @@ "projectMetadataMessage": "Updates made in this section will update the project.", "projectContributors": "Project Contributors", "collectionMetadata": "Collection Metadata", + "cedarFormNotAvailable": "CEDAR metadata form is not available for this collection.", "tooltipMessage": "Complete previous step to edit this section", "contributorsTooltip": "Projects must have at least one registered administrator and one author showing in the citation at all times. A registered administrator is a user who has both confirmed their account and has administrator privileges.", "noDescription": "No description", @@ -1411,6 +1412,7 @@ "projectMetadataUpdateSuccess": "Project Metadata successfully updated.", "confirmationDialogMessage": "Once submitted to the collection, the project will be made public. It can later be made private again. A moderator will review your submission before it is included in the collection.", "confirmationDialogToastMessage": "Project has been successfully submitted to the collection", + "updateError": "Failed to submit to the collection. Please try again.", "form": { "title": "Title", "description": "Description", From ad9ed4a5cdfe1425eed29d1968787585e4660b7c Mon Sep 17 00:00:00 2001 From: Vlad0n20 Date: Thu, 23 Apr 2026 12:48:14 +0300 Subject: [PATCH 02/32] feat(ENG-9817): fix comments --- .../add-to-collection.component.ts | 15 ++-- ...collection-metadata-step.component.spec.ts | 44 ++---------- .../collection-metadata-step.component.ts | 14 ++-- ...metadata-collection-item.component.spec.ts | 72 +++---------------- .../metadata-collections.component.ts | 22 ++---- .../features/metadata/metadata.component.html | 2 +- .../features/metadata/metadata.component.ts | 2 + .../mappers/collections/collections.mapper.ts | 4 +- .../data/collections/cedar-metadata.mock.ts | 67 +++++++++++++++++ 9 files changed, 107 insertions(+), 135 deletions(-) create mode 100644 src/testing/data/collections/cedar-metadata.mock.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 f8b5d43f2..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 @@ -294,18 +294,17 @@ export class AddToCollectionComponent implements CanDeactivateComponent { } private saveCedarRecordIfNeeded(): Observable { + if (!this.isCedarMode()) return of(null); + const cedarData = this.pendingCedarData(); const projectId = this.selectedProject()?.id; const templateId = this.requiredMetadataTemplate()?.id; - if (!this.isCedarMode() || !cedarData || !projectId || !templateId) { - return of(null); - } + if (!cedarData || !projectId || !templateId) return of(null); - const existing = this.existingCedarRecord(); - if (existing?.id) { - return this.actions.updateCedarRecord(cedarData, existing.id, projectId, ResourceType.Project); - } - return this.actions.createCedarRecord(cedarData, projectId, ResourceType.Project); + 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 { 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 441e21cd3..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 @@ -10,44 +10,12 @@ import { AddToCollectionSelectors } from '@osf/features/collections/store/add-to import { CedarMetadataDataTemplateJsonApi, CedarMetadataRecordData } from '@osf/features/metadata/models'; import { CollectionsSelectors } from '@shared/stores/collections'; +import { MOCK_CEDAR_TEMPLATE } from '@testing/data/collections/cedar-metadata.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { provideMockStore } from '@testing/providers/store-provider.mock'; import { CollectionMetadataStepComponent } from './collection-metadata-step.component'; -const MOCK_CEDAR_TEMPLATE: CedarMetadataDataTemplateJsonApi = { - id: 'template-1', - type: 'cedar-metadata-templates', - attributes: { - schema_name: 'Test Template', - cedar_id: 'cedar-1', - template: { - '@id': 'https://repo.metadatacenter.org/templates/1', - '@type': 'https://schema.metadatacenter.org/core/Template', - type: 'object', - title: 'Test', - description: 'Test template', - $schema: 'http://json-schema.org/draft-04/schema#', - '@context': { - pav: 'http://purl.org/pav/', - xsd: 'http://www.w3.org/2001/XMLSchema#', - bibo: 'http://purl.org/ontology/bibo/', - oslc: 'http://open-services.net/ns/core#', - schema: 'http://schema.org/', - 'schema:name': { '@type': 'xsd:string' }, - 'pav:createdBy': { '@type': '@id' }, - 'pav:createdOn': { '@type': 'xsd:dateTime' }, - 'oslc:modifiedBy': { '@type': '@id' }, - 'pav:lastUpdatedOn': { '@type': 'xsd:dateTime' }, - 'schema:description': { '@type': 'xsd:string' }, - }, - required: [], - properties: {}, - _ui: { order: [], propertyLabels: {}, propertyDescriptions: {} }, - }, - }, -}; - describe('CollectionMetadataStepComponent', () => { let component: CollectionMetadataStepComponent; let fixture: ComponentFixture; @@ -181,7 +149,7 @@ describe('CollectionMetadataStepComponent', () => { it('should handle discard changes with existing record in CEDAR mode', () => { const existingRecord: CedarMetadataRecordData = { attributes: { - metadata: { field: 'original' } as CedarMetadataRecordData['attributes']['metadata'], + metadata: { field: 'original' } as unknown as CedarMetadataRecordData['attributes']['metadata'], is_published: false, }, relationships: { @@ -201,7 +169,7 @@ describe('CollectionMetadataStepComponent', () => { it('should populate cedarFormData from existingCedarRecord', () => { const existingRecord: CedarMetadataRecordData = { attributes: { - metadata: { field: 'existing' } as CedarMetadataRecordData['attributes']['metadata'], + metadata: { field: 'existing' } as unknown as CedarMetadataRecordData['attributes']['metadata'], is_published: true, }, relationships: { @@ -216,8 +184,8 @@ describe('CollectionMetadataStepComponent', () => { }); it('should emit cedarDataSaved when handleSaveCedarMetadata is called without editor', () => { - const cedarDataSavedSpy = jest.spyOn(component.cedarDataSaved, 'emit'); - const stepChangeSpy = jest.spyOn(component.stepChange, 'emit'); + const cedarDataSavedSpy = vi.spyOn(component.cedarDataSaved, 'emit'); + const stepChangeSpy = vi.spyOn(component.stepChange, 'emit'); component.handleSaveCedarMetadata(); @@ -240,7 +208,7 @@ describe('CollectionMetadataStepComponent', () => { fixture.componentRef.setInput('cedarTemplate', null); fixture.detectChanges(); - const cedarDataSavedSpy = jest.spyOn(component.cedarDataSaved, 'emit'); + const cedarDataSavedSpy = vi.spyOn(component.cedarDataSaved, 'emit'); component.handleSaveCedarMetadata(); 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 e0d57b247..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 @@ -17,7 +17,7 @@ import { input, output, signal, - ViewChild, + viewChild, ViewEncapsulation, } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; @@ -83,8 +83,8 @@ export class CollectionMetadataStepComponent { cedarConfig = CEDAR_CONFIG; cedarViewerConfig = CEDAR_VIEWER_CONFIG; - @ViewChild('cedarEditor') cedarEditor?: ElementRef; - @ViewChild('cedarViewer') cedarViewer?: ElementRef; + cedarEditor = viewChild>('cedarEditor'); + cedarViewer = viewChild>('cedarViewer'); private readonly actions = createDispatchMap({ getCollectionDetails: GetCollectionDetails }); @@ -102,7 +102,7 @@ export class CollectionMetadataStepComponent { this.cedarFormData.set( record?.attributes?.metadata ? (record.attributes.metadata as Record) : {} ); - const editor = this.cedarEditor?.nativeElement; + const editor = this.cedarEditor()?.nativeElement; if (editor) { editor.instanceObject = this.cedarFormData(); } @@ -131,7 +131,7 @@ export class CollectionMetadataStepComponent { } handleSaveCedarMetadata() { - const editor = this.cedarEditor?.nativeElement; + const editor = this.cedarEditor()?.nativeElement; const template = this.cedarTemplate(); if (!editor || !template) return; @@ -198,9 +198,9 @@ export class CollectionMetadataStepComponent { if (record?.attributes?.metadata) { const metadata = record.attributes.metadata as Record; this.cedarFormData.set(metadata); - const editor = this.cedarEditor?.nativeElement; + const editor = this.cedarEditor()?.nativeElement; if (editor) editor.instanceObject = metadata; - const viewer = this.cedarViewer?.nativeElement; + const viewer = this.cedarViewer()?.nativeElement; if (viewer) viewer.instanceObject = metadata; } }); diff --git a/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.spec.ts b/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.spec.ts index 7855ea8ca..1ed5237ae 100644 --- a/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.spec.ts +++ b/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.spec.ts @@ -1,77 +1,21 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; -import { CedarMetadataDataTemplateJsonApi, CedarMetadataRecordData } from '@osf/features/metadata/models'; import { CollectionSubmissionReviewState } from '@osf/shared/enums/collection-submission-review-state.enum'; import { CollectionSubmission } from '@osf/shared/models/collections/collections.model'; +import { + MOCK_CEDAR_RECORD, + MOCK_CEDAR_SUBMISSION, + MOCK_CEDAR_TEMPLATE, +} from '@testing/data/collections/cedar-metadata.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { MetadataCollectionItemComponent } from './metadata-collection-item.component'; -const mockSubmission: CollectionSubmission = { - id: '1', - type: 'collection-submission', - collectionTitle: 'Test Collection', - collectionId: 'collection-123', - reviewsState: CollectionSubmissionReviewState.Pending, - collectedType: 'preprint', - status: 'pending', - volume: '1', - issue: '1', - programArea: 'Science', - schoolType: 'University', - studyDesign: 'Experimental', - dataType: 'Quantitative', - disease: 'Cancer', - gradeLevels: 'Graduate', - requiredMetadataTemplateId: 'template-1', -}; - -const mockCedarTemplate: CedarMetadataDataTemplateJsonApi = { - id: 'template-1', - type: 'cedar-metadata-templates', - attributes: { - schema_name: 'Test Template', - cedar_id: 'cedar-1', - template: { - '@id': 'https://repo.metadatacenter.org/templates/1', - '@type': 'https://schema.metadatacenter.org/core/Template', - type: 'object', - title: 'Test', - description: 'Test template', - $schema: 'http://json-schema.org/draft-04/schema#', - '@context': { - pav: 'http://purl.org/pav/', - xsd: 'http://www.w3.org/2001/XMLSchema#', - bibo: 'http://purl.org/ontology/bibo/', - oslc: 'http://open-services.net/ns/core#', - schema: 'http://schema.org/', - 'schema:name': { '@type': 'xsd:string' }, - 'pav:createdBy': { '@type': '@id' }, - 'pav:createdOn': { '@type': 'xsd:dateTime' }, - 'oslc:modifiedBy': { '@type': '@id' }, - 'pav:lastUpdatedOn': { '@type': 'xsd:dateTime' }, - 'schema:description': { '@type': 'xsd:string' }, - }, - required: [], - properties: {}, - _ui: { order: [], propertyLabels: {}, propertyDescriptions: {} }, - }, - }, -}; - -const mockCedarRecord: CedarMetadataRecordData = { - id: 'record-1', - attributes: { - metadata: { field: 'value' } as CedarMetadataRecordData['attributes']['metadata'], - is_published: true, - }, - relationships: { - template: { data: { type: 'cedar-metadata-templates', id: 'template-1' } }, - target: { data: { type: 'nodes', id: 'node-1' } }, - }, -}; +const mockSubmission: CollectionSubmission = MOCK_CEDAR_SUBMISSION; +const mockCedarTemplate = MOCK_CEDAR_TEMPLATE; +const mockCedarRecord = MOCK_CEDAR_RECORD; describe('MetadataCollectionItemComponent', () => { let component: MetadataCollectionItemComponent; diff --git a/src/app/features/metadata/components/metadata-collections/metadata-collections.component.ts b/src/app/features/metadata/components/metadata-collections/metadata-collections.component.ts index 8bade53f8..950d7e2ac 100644 --- a/src/app/features/metadata/components/metadata-collections/metadata-collections.component.ts +++ b/src/app/features/metadata/components/metadata-collections/metadata-collections.component.ts @@ -26,24 +26,16 @@ export class MetadataCollectionsComponent { cedarRecordByTemplateId = computed(() => { const records = this.cedarRecords(); - if (!records?.length) return new Map(); - const map = new Map(); - for (const record of records) { - const templateId = record.relationships?.template?.data?.id; - if (templateId) { - map.set(templateId, record); - } - } - return map; + return new Map( + records?.flatMap((record) => { + const templateId = record.relationships?.template?.data?.id; + return templateId ? [[templateId, record] as const] : []; + }) ?? [] + ); }); cedarTemplateById = computed(() => { const templates = this.cedarTemplates(); - if (!templates?.length) return new Map(); - const map = new Map(); - for (const template of templates) { - map.set(template.id, template); - } - return map; + return new Map(templates?.map((t) => [t.id, t] as const) ?? []); }); } diff --git a/src/app/features/metadata/metadata.component.html b/src/app/features/metadata/metadata.component.html index 54791a2fe..e49c5490b 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]="environment.collectionSubmissionWithCedar" + [isCedarMode]="collectionSubmissionWithCedar" /> }
diff --git a/src/app/features/metadata/metadata.component.ts b/src/app/features/metadata/metadata.component.ts index ad6f68623..ef00699e8 100644 --- a/src/app/features/metadata/metadata.component.ts +++ b/src/app/features/metadata/metadata.component.ts @@ -128,6 +128,8 @@ export class MetadataComponent implements OnInit, OnDestroy { private readonly environment = inject(ENVIRONMENT); private readonly signpostingService = inject(SignpostingService); + readonly collectionSubmissionWithCedar = this.environment.collectionSubmissionWithCedar; + private resourceId = ''; tabs = signal([]); diff --git a/src/app/shared/mappers/collections/collections.mapper.ts b/src/app/shared/mappers/collections/collections.mapper.ts index 7f8b301bf..cd7711c26 100644 --- a/src/app/shared/mappers/collections/collections.mapper.ts +++ b/src/app/shared/mappers/collections/collections.mapper.ts @@ -153,8 +153,8 @@ export class CollectionsMapper { gradeLevels: submission.attributes.grade_levels, creator: creator ? { - id: creator.id, - fullName: creator.fullName, + id: creator?.id, + fullName: creator?.fullName, } : undefined, } as CollectionSubmissionWithGuid; diff --git a/src/testing/data/collections/cedar-metadata.mock.ts b/src/testing/data/collections/cedar-metadata.mock.ts new file mode 100644 index 000000000..4fbb297c3 --- /dev/null +++ b/src/testing/data/collections/cedar-metadata.mock.ts @@ -0,0 +1,67 @@ +import { CedarMetadataDataTemplateJsonApi, CedarMetadataRecordData } from '@osf/features/metadata/models'; +import { CollectionSubmissionReviewState } from '@osf/shared/enums/collection-submission-review-state.enum'; +import { CollectionSubmission } from '@osf/shared/models/collections/collections.model'; + +export const MOCK_CEDAR_TEMPLATE: CedarMetadataDataTemplateJsonApi = { + id: 'template-1', + type: 'cedar-metadata-templates', + attributes: { + schema_name: 'Test Template', + cedar_id: 'cedar-1', + template: { + '@id': 'https://repo.metadatacenter.org/templates/1', + '@type': 'https://schema.metadatacenter.org/core/Template', + type: 'object', + title: 'Test', + description: 'Test template', + $schema: 'http://json-schema.org/draft-04/schema#', + '@context': { + pav: 'http://purl.org/pav/', + xsd: 'http://www.w3.org/2001/XMLSchema#', + bibo: 'http://purl.org/ontology/bibo/', + oslc: 'http://open-services.net/ns/core#', + schema: 'http://schema.org/', + 'schema:name': { '@type': 'xsd:string' }, + 'pav:createdBy': { '@type': '@id' }, + 'pav:createdOn': { '@type': 'xsd:dateTime' }, + 'oslc:modifiedBy': { '@type': '@id' }, + 'pav:lastUpdatedOn': { '@type': 'xsd:dateTime' }, + 'schema:description': { '@type': 'xsd:string' }, + }, + required: [], + properties: {}, + _ui: { order: [], propertyLabels: {}, propertyDescriptions: {} }, + }, + }, +}; + +export const MOCK_CEDAR_RECORD: CedarMetadataRecordData = { + id: 'record-1', + attributes: { + metadata: { field: 'value' } as unknown as CedarMetadataRecordData['attributes']['metadata'], + is_published: true, + }, + relationships: { + template: { data: { type: 'cedar-metadata-templates', id: 'template-1' } }, + target: { data: { type: 'nodes', id: 'node-1' } }, + }, +}; + +export const MOCK_CEDAR_SUBMISSION: CollectionSubmission = { + id: '1', + type: 'collection-submission', + collectionTitle: 'Test Collection', + collectionId: 'collection-123', + reviewsState: CollectionSubmissionReviewState.Pending, + collectedType: 'preprint', + status: 'pending', + volume: '1', + issue: '1', + programArea: 'Science', + schoolType: 'University', + studyDesign: 'Experimental', + dataType: 'Quantitative', + disease: 'Cancer', + gradeLevels: 'Graduate', + requiredMetadataTemplateId: 'template-1', +}; From 70299052d24018c2665d0008e6058bfb962e3cf8 Mon Sep 17 00:00:00 2001 From: Vlad0n20 Date: Thu, 23 Apr 2026 14:29:21 +0300 Subject: [PATCH 03/32] feat(ENG-9817): fix comment --- .../metadata-collections.component.spec.ts | 80 +++---------------- 1 file changed, 9 insertions(+), 71 deletions(-) diff --git a/src/app/features/metadata/components/metadata-collections/metadata-collections.component.spec.ts b/src/app/features/metadata/components/metadata-collections/metadata-collections.component.spec.ts index d0a8bf983..9d446b991 100644 --- a/src/app/features/metadata/components/metadata-collections/metadata-collections.component.spec.ts +++ b/src/app/features/metadata/components/metadata-collections/metadata-collections.component.spec.ts @@ -4,83 +4,21 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { ActivatedRoute } from '@angular/router'; -import { CedarMetadataDataTemplateJsonApi, CedarMetadataRecordData } from '@osf/features/metadata/models'; -import { CollectionSubmissionReviewState } from '@osf/shared/enums/collection-submission-review-state.enum'; -import { CollectionSubmission } from '@osf/shared/models/collections/collections.model'; - +import { + MOCK_CEDAR_RECORD, + MOCK_CEDAR_SUBMISSION, + MOCK_CEDAR_TEMPLATE, +} from '@testing/data/collections/cedar-metadata.mock'; import { MOCK_PROJECT_COLLECTION_SUBMISSIONS } from '@testing/data/collections/collection-submissions.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; import { MetadataCollectionsComponent } from './metadata-collections.component'; -const mockTemplateId = 'template-1'; - -const mockCedarTemplate: CedarMetadataDataTemplateJsonApi = { - id: mockTemplateId, - type: 'cedar-metadata-templates', - attributes: { - schema_name: 'Test', - cedar_id: 'cedar-1', - template: { - '@id': '', - '@type': '', - type: 'object', - title: 'Test', - description: '', - $schema: '', - '@context': { - pav: '', - xsd: '', - bibo: '', - oslc: '', - schema: '', - '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: {} }, - }, - }, -}; - -const mockCedarRecord: CedarMetadataRecordData = { - id: 'record-1', - attributes: { - metadata: {} as CedarMetadataRecordData['attributes']['metadata'], - is_published: false, - }, - relationships: { - template: { data: { type: 'cedar-metadata-templates', id: mockTemplateId } }, - target: { data: { type: 'nodes', id: 'node-1' } }, - }, -}; - -const mockSubmissionsWithTemplate: CollectionSubmission[] = [ - { - id: '1', - type: 'collection-submissions', - collectionTitle: 'Collection A', - collectionId: 'col1', - reviewsState: CollectionSubmissionReviewState.Accepted, - collectedType: '', - status: 'accepted', - volume: '', - issue: '', - programArea: '', - schoolType: '', - studyDesign: '', - dataType: '', - disease: '', - gradeLevels: '', - requiredMetadataTemplateId: mockTemplateId, - }, -]; +const mockTemplateId = MOCK_CEDAR_TEMPLATE.id; +const mockCedarTemplate = MOCK_CEDAR_TEMPLATE; +const mockCedarRecord = MOCK_CEDAR_RECORD; +const mockSubmissionsWithTemplate = [MOCK_CEDAR_SUBMISSION]; describe('MetadataCollectionsComponent', () => { let component: MetadataCollectionsComponent; From 874450feb6c34c6134e4ada92c8e8ea9595d2edb Mon Sep 17 00:00:00 2001 From: Vlad0n20 Date: Tue, 28 Apr 2026 12:36:05 +0300 Subject: [PATCH 04/32] feat(ENG-9818): collection search with shtrove and cedar filters --- .../collections-discover.component.html | 8 +- .../collections-discover.component.spec.ts | 300 +++++++++++++----- .../collections-discover.component.ts | 97 ++++-- .../cedar-template-filter.mapper.spec.ts | 83 +++++ .../filters/cedar-template-filter.mapper.ts | 16 + .../global-search/global-search.actions.ts | 8 +- .../global-search/global-search.model.ts | 2 + .../global-search/global-search.state.ts | 12 +- .../providers/environment.token.mock.ts | 1 + 9 files changed, 418 insertions(+), 109 deletions(-) create mode 100644 src/app/shared/mappers/filters/cedar-template-filter.mapper.spec.ts create mode 100644 src/app/shared/mappers/filters/cedar-template-filter.mapper.ts 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 1e9261f03..1d2fe31b5 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,13 @@

{{ collectionProvider()?
- + @if (useShtrovSearch) { + @if (defaultSearchFiltersInitialized()) { + + } + } @else { + + }
} @else { diff --git a/src/app/features/collections/components/collections-discover/collections-discover.component.spec.ts b/src/app/features/collections/components/collections-discover/collections-discover.component.spec.ts index 09693f727..eadd12c3c 100644 --- a/src/app/features/collections/components/collections-discover/collections-discover.component.spec.ts +++ b/src/app/features/collections/components/collections-discover/collections-discover.component.spec.ts @@ -1,129 +1,261 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; +import { Mock } from 'vitest'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; +import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { GlobalSearchComponent } from '@osf/shared/components/global-search/global-search.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { CollectionsSelectors } from '@shared/stores/collections'; +import { SetDefaultFilterValue, SetExtraFilters } from '@shared/stores/global-search'; import { MOCK_PROVIDER } from '@testing/mocks/provider.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; -import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock'; +import { ToastServiceMock } from '@testing/providers/toast-provider.mock'; import { CollectionsQuerySyncService } from '../../services'; import { CollectionsMainContentComponent } from '../collections-main-content/collections-main-content.component'; import { CollectionsDiscoverComponent } from './collections-discover.component'; -describe('CollectionsDiscoverComponent', () => { - let component: CollectionsDiscoverComponent; - let fixture: ComponentFixture; - let toastServiceMock: ToastServiceMockType; - let mockCustomDialogService: ReturnType; - let mockRoute: ReturnType; - - beforeEach(() => { - toastServiceMock = ToastServiceMock.simple(); - mockCustomDialogService = CustomDialogServiceMockBuilder.create().build(); - mockRoute = ActivatedRouteMockBuilder.create().withParams({ providerId: 'provider-1' }).build(); - - TestBed.configureTestingModule({ - imports: [ - CollectionsDiscoverComponent, - ...MockComponents(SearchInputComponent, CollectionsMainContentComponent, LoadingSpinnerComponent), - ], - providers: [ - provideOSFCore(), - MockProvider(ToastService, toastServiceMock), - MockProvider(CustomDialogService, mockCustomDialogService), - MockProvider(ActivatedRoute, mockRoute), - provideMockStore({ - signals: [ - { selector: CollectionsSelectors.getCollectionProvider, value: MOCK_PROVIDER }, - { selector: CollectionsSelectors.getCollectionDetails, value: null }, - { selector: CollectionsSelectors.getAllSelectedFilters, value: {} }, - { selector: CollectionsSelectors.getSortBy, value: 'date' }, - { selector: CollectionsSelectors.getSearchText, value: '' }, - { selector: CollectionsSelectors.getPageNumber, value: '1' }, - { selector: CollectionsSelectors.getCollectionProviderLoading, value: false }, - ], - }), - ], - }).overrideComponent(CollectionsDiscoverComponent, { - set: { - providers: [MockProvider(CollectionsQuerySyncService)], +const MOCK_COLLECTION_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: {}, + }, }, - }); + }, + }, +}; - fixture = TestBed.createComponent(CollectionsDiscoverComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); +interface SetupOptions { + collectionSubmissionWithCedar?: boolean; + provider?: typeof MOCK_COLLECTION_PROVIDER | typeof MOCK_COLLECTION_PROVIDER_WITH_TEMPLATE; +} - it('should create', () => { - expect(component).toBeTruthy(); - }); +function setup(options: SetupOptions = {}) { + const { collectionSubmissionWithCedar = false, provider = MOCK_COLLECTION_PROVIDER } = options; + + const toastServiceMock = ToastServiceMock.simple(); + const mockCustomDialogService = CustomDialogServiceMockBuilder.create().build(); + const mockRoute = ActivatedRouteMockBuilder.create().withParams({ providerId: 'provider-1' }).build(); - it('should initialize with default values', () => { - expect(component.providerId()).toBe('provider-1'); - expect(component.searchControl.value).toBe(''); + TestBed.configureTestingModule({ + imports: [ + CollectionsDiscoverComponent, + ...MockComponents( + SearchInputComponent, + CollectionsMainContentComponent, + GlobalSearchComponent, + LoadingSpinnerComponent + ), + ], + providers: [ + provideOSFCore(), + { provide: ENVIRONMENT, useValue: { apiDomainUrl: 'http://localhost:8000', collectionSubmissionWithCedar } }, + MockProvider(ToastService, toastServiceMock), + MockProvider(CustomDialogService, mockCustomDialogService), + MockProvider(ActivatedRoute, mockRoute), + provideMockStore({ + signals: [ + { selector: CollectionsSelectors.getCollectionProvider, value: provider }, + { selector: CollectionsSelectors.getCollectionDetails, value: 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)], + }, }); - it('should handle search triggered', () => { - const searchValue = 'test search'; + const fixture = TestBed.createComponent(CollectionsDiscoverComponent); + const component = fixture.componentInstance; + const store = TestBed.inject(Store); + fixture.detectChanges(); - component.onSearchTriggered(searchValue); + return { fixture, component, store }; +} - expect(component).toBeTruthy(); - }); +describe('CollectionsDiscoverComponent', () => { + describe('legacy mode (collectionSubmissionWithCedar = false)', () => { + let component: CollectionsDiscoverComponent; + let fixture: ComponentFixture; - it('should have provider id signal', () => { - expect(component.providerId()).toBe('provider-1'); - }); + beforeEach(() => { + ({ fixture, component } = setup()); + }); - it('should have collection provider data', () => { - expect(component.collectionProvider()).toEqual(MOCK_PROVIDER); - }); + it('should create', () => { + expect(component).toBeTruthy(); + }); - it('should have collection details', () => { - expect(component.collectionDetails()).toBeNull(); - }); + it('should set useShtrovSearch to false', () => { + expect(component.useShtrovSearch).toBe(false); + }); - it('should have selected filters', () => { - expect(component.selectedFilters()).toEqual({}); - }); + it('should initialize with default values', () => { + expect(component.providerId()).toBe('provider-1'); + expect(component.searchControl.value).toBe(''); + }); - it('should have sort by value', () => { - expect(component.sortBy()).toBe('date'); - }); + it('should have collection provider data', () => { + expect(component.collectionProvider()).toEqual(MOCK_COLLECTION_PROVIDER); + }); - it('should have search text', () => { - expect(component.searchText()).toBe(''); - }); + it('should have collection details as null', () => { + expect(component.collectionDetails()).toBeNull(); + }); - it('should have page number', () => { - expect(component.pageNumber()).toBe('1'); - }); + it('should have selected filters', () => { + expect(component.selectedFilters()).toEqual({}); + }); - it('should have loading state', () => { - expect(component.isProviderLoading()).toBe(false); - }); + it('should have sort by value', () => { + expect(component.sortBy()).toBe('date'); + }); + + it('should have search text', () => { + expect(component.searchText()).toBe(''); + }); - it('should compute primary collection id', () => { - expect(component.primaryCollectionId()).toBe(MOCK_PROVIDER.primaryCollection?.id); + it('should have page number', () => { + expect(component.pageNumber()).toBe('1'); + }); + + it('should have loading state', () => { + expect(component.isProviderLoading()).toBe(false); + }); + + it('should compute primary collection id', () => { + expect(component.primaryCollectionId()).toBe('collection-1'); + }); + + it('should handle search control value changes', () => { + component.searchControl.setValue('new search value'); + expect(component.searchControl.value).toBe('new search value'); + }); + + it('should not initialize default search filters', () => { + expect(component.defaultSearchFiltersInitialized()).toBe(false); + }); + + it('should render CollectionsMainContentComponent', () => { + const el = fixture.nativeElement as HTMLElement; + expect(el.querySelector('osf-collections-main-content')).toBeTruthy(); + expect(el.querySelector('osf-global-search')).toBeNull(); + }); + + it('should dispatch setSearchValue and setPageNumber on search triggered', () => { + const { component: localComponent, store: localStore } = setup(); + (localStore.dispatch as Mock).mockClear(); + + localComponent.onSearchTriggered('my query'); + + const calls = (localStore.dispatch as Mock).mock.calls.flat(); + expect(calls.some((c: unknown) => c instanceof SetDefaultFilterValue)).toBe(false); + }); }); - it('should handle search control value changes', () => { - const searchValue = 'new search value'; + describe('shtrove mode (collectionSubmissionWithCedar = true)', () => { + it('should set useShtrovSearch to true', () => { + const { component } = setup({ collectionSubmissionWithCedar: true }); + expect(component.useShtrovSearch).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(); - component.searchControl.setValue(searchValue); + 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, + }); - expect(component.searchControl.value).toBe(searchValue); + const dispatched = (store.dispatch as Mock).mock.calls.flat(); + const setExtraFilters = dispatched.find((c: unknown) => c instanceof SetExtraFilters) as SetExtraFilters; + + expect(setExtraFilters).toBeDefined(); + expect(setExtraFilters.filters).toHaveLength(1); + expect(setExtraFilters.filters[0].key).toBe('field1'); + expect(setExtraFilters.filters[0].label).toBe('Field One'); + }); + + it('should render GlobalSearchComponent when filters are initialized', () => { + const { fixture } = setup({ collectionSubmissionWithCedar: true }); + const el = fixture.nativeElement as HTMLElement; + + expect(el.querySelector('osf-global-search')).toBeTruthy(); + expect(el.querySelector('osf-collections-main-content')).toBeNull(); + }); + + it('should not dispatch any action on onSearchTriggered in shtrove mode', () => { + const { component, store } = setup({ collectionSubmissionWithCedar: true }); + (store.dispatch as Mock).mockClear(); + + component.onSearchTriggered('query'); + + expect(store.dispatch).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/app/features/collections/components/collections-discover/collections-discover.component.ts b/src/app/features/collections/components/collections-discover/collections-discover.component.ts index 0c43f26cb..b9de99091 100644 --- a/src/app/features/collections/components/collections-discover/collections-discover.component.ts +++ b/src/app/features/collections/components/collections-discover/collections-discover.component.ts @@ -21,8 +21,11 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { GlobalSearchComponent } from '@osf/shared/components/global-search/global-search.component'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component'; +import { CedarTemplateFilterMapper } from '@osf/shared/mappers/filters/cedar-template-filter.mapper'; import { CollectionsFilters } from '@osf/shared/models/collections/collections-filters.model'; import { BrandService } from '@osf/shared/services/brand.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; @@ -37,6 +40,7 @@ import { SetPageNumber, SetSearchValue, } from '@osf/shared/stores/collections'; +import { ResetSearchState, SetDefaultFilterValue, SetExtraFilters } from '@osf/shared/stores/global-search'; import { CollectionsQuerySyncService } from '../../services'; import { CollectionsHelpDialogComponent } from '../collections-help-dialog/collections-help-dialog.component'; @@ -49,6 +53,7 @@ import { CollectionsMainContentComponent } from '../collections-main-content/col RouterLink, SearchInputComponent, CollectionsMainContentComponent, + GlobalSearchComponent, LoadingSpinnerComponent, TranslatePipe, ], @@ -66,10 +71,14 @@ 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(''); providerId = signal(''); + defaultSearchFiltersInitialized = signal(false); + + readonly useShtrovSearch: boolean = this.environment.collectionSubmissionWithCedar; collectionProvider = select(CollectionsSelectors.getCollectionProvider); collectionDetails = select(CollectionsSelectors.getCollectionDetails); @@ -89,12 +98,34 @@ export class CollectionsDiscoverComponent { setPageNumber: SetPageNumber, clearCollections: ClearCollections, clearCollectionsSubmissions: ClearCollectionSubmissions, + setDefaultFilterValue: SetDefaultFilterValue, + setExtraFilters: SetExtraFilters, + resetSearchState: ResetSearchState, }); constructor() { this.initializeProvider(); - this.setupEffects(); - this.setupSearchBinding(); + this.setupBrandingEffect(); + + if (this.useShtrovSearch) { + this.setupShtrovSearchEffect(); + } else { + this.setupCollectionDetailsEffect(); + this.setupUrlSyncEffect(); + this.setupLegacySearchEffect(); + this.setupSearchBinding(); + } + + this.destroyRef.onDestroy(() => { + if (this.isBrowser) { + this.actions.clearCollections(); + if (this.useShtrovSearch) { + this.actions.resetSearchState(); + } + this.headerStyleHelper.resetToDefaults(); + this.brandService.resetBranding(); + } + }); } openHelpDialog(): void { @@ -102,8 +133,10 @@ export class CollectionsDiscoverComponent { } onSearchTriggered(searchValue: string): void { - this.actions.setSearchValue(searchValue); - this.actions.setPageNumber('1'); + if (!this.useShtrovSearch) { + this.actions.setSearchValue(searchValue); + this.actions.setPageNumber('1'); + } } private initializeProvider(): void { @@ -117,24 +150,50 @@ export class CollectionsDiscoverComponent { this.actions.getCollectionProvider(id); } - private setupEffects(): void { - this.querySyncService.initializeFromUrl(); - + private setupBrandingEffect(): void { effect(() => { - const collectionId = this.primaryCollectionId(); - if (collectionId) { - this.actions.getCollectionDetails(collectionId); + const provider = this.collectionProvider(); + + if (provider?.brand) { + this.brandService.applyBranding(provider.brand); + this.headerStyleHelper.applyHeaderStyles(provider.brand.secondaryColor, provider.brand.backgroundColor || ''); } }); + } + private setupShtrovSearchEffect(): void { effect(() => { const provider = this.collectionProvider(); + const collectionId = this.primaryCollectionId(); - if (provider && provider.brand) { - this.brandService.applyBranding(provider.brand); - this.headerStyleHelper.applyHeaderStyles(provider.brand.secondaryColor, provider.brand.backgroundColor || ''); + if (!provider || !collectionId || this.defaultSearchFiltersInitialized()) return; + + const collectionIri = `${this.environment.apiDomainUrl}/v2/collections/${collectionId}/`; + // TODO(ENG-9818): verify 'isContainedBy' property path against shtrove API before shipping + this.actions.setDefaultFilterValue('isContainedBy', collectionIri); + + if (provider.requiredMetadataTemplate?.attributes?.template) { + const extraFilters = CedarTemplateFilterMapper.fromTemplate( + provider.requiredMetadataTemplate.attributes.template + ); + this.actions.setExtraFilters(extraFilters); + } + + this.defaultSearchFiltersInitialized.set(true); + }); + } + + private setupCollectionDetailsEffect(): void { + effect(() => { + const collectionId = this.primaryCollectionId(); + if (collectionId) { + this.actions.getCollectionDetails(collectionId); } }); + } + + private setupUrlSyncEffect(): void { + this.querySyncService.initializeFromUrl(); effect(() => { const searchText = this.searchText(); @@ -146,7 +205,9 @@ export class CollectionsDiscoverComponent { this.querySyncService.syncStoreToUrl(searchText, sortBy, selectedFilters, pageNumber); } }); + } + private setupLegacySearchEffect(): void { effect(() => { const searchText = this.searchText(); const sortBy = this.sortBy(); @@ -161,19 +222,11 @@ export class CollectionsDiscoverComponent { this.actions.searchCollectionSubmissions(providerId, searchText, activeFilters, pageNumber, sortBy); } }); - - this.destroyRef.onDestroy(() => { - if (this.isBrowser) { - this.actions.clearCollections(); - this.headerStyleHelper.resetToDefaults(); - this.brandService.resetBranding(); - } - }); } private getActiveFilters(filters: CollectionsFilters): Record { return Object.entries(filters) - .filter(([_, value]) => value.length) + .filter(([, value]) => value.length) .reduce( (acc, [key, value]) => { acc[key] = value; diff --git a/src/app/shared/mappers/filters/cedar-template-filter.mapper.spec.ts b/src/app/shared/mappers/filters/cedar-template-filter.mapper.spec.ts new file mode 100644 index 000000000..c537846c9 --- /dev/null +++ b/src/app/shared/mappers/filters/cedar-template-filter.mapper.spec.ts @@ -0,0 +1,83 @@ +import { CedarTemplate } from '@osf/features/metadata/models'; +import { FilterOperatorOption } from '@osf/shared/models/search/discaverable-filter.model'; + +import { CedarTemplateFilterMapper } from './cedar-template-filter.mapper'; + +function makeTemplate(order: string[], propertyLabels: Record): CedarTemplate { + return { + '@id': 'https://repo.metadatacenter.org/templates/test', + '@type': 'https://schema.metadatacenter.org/core/Template', + type: 'object', + title: 'Test Template', + description: '', + $schema: 'http://json-schema.org/draft-04/schema', + '@context': {} as CedarTemplate['@context'], + required: [], + properties: {}, + _ui: { order, propertyLabels, propertyDescriptions: {} }, + }; +} + +describe('CedarTemplateFilterMapper', () => { + describe('fromTemplate', () => { + it('maps ordered fields with labels to DiscoverableFilter array', () => { + const template = makeTemplate(['field1', 'field2'], { field1: 'Field One', field2: 'Field Two' }); + + const result = CedarTemplateFilterMapper.fromTemplate(template); + + expect(result).toEqual([ + { key: 'field1', label: 'Field One', operator: FilterOperatorOption.AnyOf }, + { key: 'field2', label: 'Field Two', operator: FilterOperatorOption.AnyOf }, + ]); + }); + + it('skips fields with empty labels', () => { + const template = makeTemplate(['field1', 'field2'], { field1: 'Field One', field2: '' }); + + const result = CedarTemplateFilterMapper.fromTemplate(template); + + expect(result).toHaveLength(1); + expect(result[0].key).toBe('field1'); + }); + + it('skips fields with whitespace-only labels', () => { + const template = makeTemplate(['field1', 'field2'], { field1: ' ', field2: 'Field Two' }); + + const result = CedarTemplateFilterMapper.fromTemplate(template); + + expect(result).toHaveLength(1); + expect(result[0].key).toBe('field2'); + }); + + it('skips fields absent from propertyLabels', () => { + const template = makeTemplate(['field1', 'unknown'], { field1: 'Field One' }); + + const result = CedarTemplateFilterMapper.fromTemplate(template); + + expect(result).toHaveLength(1); + expect(result[0].key).toBe('field1'); + }); + + it('preserves the order defined in _ui.order', () => { + const template = makeTemplate(['b', 'a', 'c'], { a: 'A', b: 'B', c: 'C' }); + + const result = CedarTemplateFilterMapper.fromTemplate(template); + + expect(result.map((f) => f.key)).toEqual(['b', 'a', 'c']); + }); + + it('returns an empty array when order is empty', () => { + const template = makeTemplate([], {}); + + expect(CedarTemplateFilterMapper.fromTemplate(template)).toEqual([]); + }); + + it('sets operator to AnyOf for all fields', () => { + const template = makeTemplate(['f1', 'f2'], { f1: 'F1', f2: 'F2' }); + + const result = CedarTemplateFilterMapper.fromTemplate(template); + + result.forEach((f) => expect(f.operator).toBe(FilterOperatorOption.AnyOf)); + }); + }); +}); diff --git a/src/app/shared/mappers/filters/cedar-template-filter.mapper.ts b/src/app/shared/mappers/filters/cedar-template-filter.mapper.ts new file mode 100644 index 000000000..44d32e9d8 --- /dev/null +++ b/src/app/shared/mappers/filters/cedar-template-filter.mapper.ts @@ -0,0 +1,16 @@ +import { CedarTemplate } from '@osf/features/metadata/models'; +import { DiscoverableFilter, FilterOperatorOption } from '@osf/shared/models/search/discaverable-filter.model'; + +export class CedarTemplateFilterMapper { + static fromTemplate(template: CedarTemplate): DiscoverableFilter[] { + const { order, propertyLabels } = template._ui; + + return order + .filter((key) => propertyLabels[key]?.trim()) + .map((key) => ({ + key, + label: propertyLabels[key], + operator: FilterOperatorOption.AnyOf, + })); + } +} diff --git a/src/app/shared/stores/global-search/global-search.actions.ts b/src/app/shared/stores/global-search/global-search.actions.ts index 00dfa8d38..dcf59ed74 100644 --- a/src/app/shared/stores/global-search/global-search.actions.ts +++ b/src/app/shared/stores/global-search/global-search.actions.ts @@ -1,6 +1,6 @@ import { StringOrNull } from '@osf/shared/helpers/types.helper'; import { ResourceType } from '@shared/enums/resource-type.enum'; -import { FilterOption } from '@shared/models/search/discaverable-filter.model'; +import { DiscoverableFilter, FilterOption } from '@shared/models/search/discaverable-filter.model'; export class FetchResources { static readonly type = '[GlobalSearch] Fetch Resources'; @@ -81,6 +81,12 @@ export class LoadMoreFilterOptions { constructor(public filterKey: string) {} } +export class SetExtraFilters { + static readonly type = '[GlobalSearch] Set Extra Filters'; + + constructor(public filters: DiscoverableFilter[]) {} +} + export class ResetSearchState { static readonly type = '[GlobalSearch] Reset Search State'; } diff --git a/src/app/shared/stores/global-search/global-search.model.ts b/src/app/shared/stores/global-search/global-search.model.ts index 2174a080c..c32508adf 100644 --- a/src/app/shared/stores/global-search/global-search.model.ts +++ b/src/app/shared/stores/global-search/global-search.model.ts @@ -7,6 +7,7 @@ import { AsyncStateModel } from '@osf/shared/models/store/async-state.model'; export interface GlobalSearchStateModel { resources: AsyncStateModel; filters: DiscoverableFilter[]; + extraFilters: DiscoverableFilter[]; defaultFilterOptions: Record; selectedFilterOptions: Record; filterOptionsCache: Record; @@ -28,6 +29,7 @@ export const GLOBAL_SEARCH_STATE_DEFAULTS = { error: null, }, filters: [], + extraFilters: [], defaultFilterOptions: {}, selectedFilterOptions: {}, filterOptionsCache: {}, diff --git a/src/app/shared/stores/global-search/global-search.state.ts b/src/app/shared/stores/global-search/global-search.state.ts index 78e7c552b..f45c946e3 100644 --- a/src/app/shared/stores/global-search/global-search.state.ts +++ b/src/app/shared/stores/global-search/global-search.state.ts @@ -20,6 +20,7 @@ import { LoadMoreFilterOptions, ResetSearchState, SetDefaultFilterValue, + SetExtraFilters, SetResourceType, SetSearchText, SetSortBy, @@ -238,6 +239,11 @@ export class GlobalSearchState { ctx.patchState({ defaultFilterOptions: updatedFilterValues }); } + @Action(SetExtraFilters) + setExtraFilters(ctx: StateContext, action: SetExtraFilters) { + ctx.patchState({ extraFilters: action.filters }); + } + @Action(UpdateSelectedFilterOption) updateSelectedFilterOption(ctx: StateContext, action: UpdateSelectedFilterOption) { const updatedFilterValues = { ...ctx.getState().selectedFilterOptions, [action.filterKey]: action.filterOption }; @@ -268,12 +274,16 @@ export class GlobalSearchState { } private updateResourcesState(ctx: StateContext, response: ResourcesData) { + const { extraFilters } = ctx.getState(); + const apiFilterKeys = new Set(response.filters.map((f) => f.key)); + const merged = [...response.filters, ...extraFilters.filter((f) => !apiFilterKeys.has(f.key))]; + ctx.patchState({ resources: { data: response.resources, isLoading: false, error: null }, filterOptionsCache: {}, filterSearchCache: {}, filterPaginationCache: {}, - filters: response.filters, + filters: merged, resourcesCount: response.count, first: response.first, next: response.next, diff --git a/src/testing/providers/environment.token.mock.ts b/src/testing/providers/environment.token.mock.ts index 02105aed2..7bc33d525 100644 --- a/src/testing/providers/environment.token.mock.ts +++ b/src/testing/providers/environment.token.mock.ts @@ -47,5 +47,6 @@ export const EnvironmentTokenMock = { newRelicLoaderConfigAgentID: '', newRelicLoaderConfigLicenseKey: '', newRelicLoaderConfigApplicationID: '', + collectionSubmissionWithCedar: false, }, }; From d45a69a009d0160a75da4ecc6b95429c2dab949d Mon Sep 17 00:00:00 2001 From: Vlad0n20 Date: Wed, 29 Apr 2026 14:44:31 +0300 Subject: [PATCH 05/32] feat(ENG-9818): fix comments --- .../collections-discover.component.html | 2 +- .../collections-discover.component.spec.ts | 8 +- .../collections-discover.component.ts | 13 ++- .../cedar-template-filter.mapper.spec.ts | 83 ------------------- 4 files changed, 11 insertions(+), 95 deletions(-) delete mode 100644 src/app/shared/mappers/filters/cedar-template-filter.mapper.spec.ts 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 1d2fe31b5..d6971525e 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 (useShtrovSearch) { + @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 eadd12c3c..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 @@ -129,8 +129,8 @@ describe('CollectionsDiscoverComponent', () => { expect(component).toBeTruthy(); }); - it('should set useShtrovSearch to false', () => { - expect(component.useShtrovSearch).toBe(false); + it('should set useShareTroveSearch to false', () => { + expect(component.useShareTroveSearch).toBe(false); }); it('should initialize with default values', () => { @@ -197,9 +197,9 @@ describe('CollectionsDiscoverComponent', () => { }); describe('shtrove mode (collectionSubmissionWithCedar = true)', () => { - it('should set useShtrovSearch to true', () => { + it('should set useShareTroveSearch to true', () => { const { component } = setup({ collectionSubmissionWithCedar: true }); - expect(component.useShtrovSearch).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 b9de99091..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 @@ -78,7 +78,7 @@ export class CollectionsDiscoverComponent { providerId = signal(''); defaultSearchFiltersInitialized = signal(false); - readonly useShtrovSearch: boolean = this.environment.collectionSubmissionWithCedar; + readonly useShareTroveSearch = this.environment.collectionSubmissionWithCedar; collectionProvider = select(CollectionsSelectors.getCollectionProvider); collectionDetails = select(CollectionsSelectors.getCollectionDetails); @@ -107,8 +107,8 @@ export class CollectionsDiscoverComponent { this.initializeProvider(); this.setupBrandingEffect(); - if (this.useShtrovSearch) { - this.setupShtrovSearchEffect(); + if (this.useShareTroveSearch) { + this.setupShareTroveSearchEffect(); } else { this.setupCollectionDetailsEffect(); this.setupUrlSyncEffect(); @@ -119,7 +119,7 @@ export class CollectionsDiscoverComponent { this.destroyRef.onDestroy(() => { if (this.isBrowser) { this.actions.clearCollections(); - if (this.useShtrovSearch) { + if (this.useShareTroveSearch) { this.actions.resetSearchState(); } this.headerStyleHelper.resetToDefaults(); @@ -133,7 +133,7 @@ export class CollectionsDiscoverComponent { } onSearchTriggered(searchValue: string): void { - if (!this.useShtrovSearch) { + if (!this.useShareTroveSearch) { this.actions.setSearchValue(searchValue); this.actions.setPageNumber('1'); } @@ -161,7 +161,7 @@ export class CollectionsDiscoverComponent { }); } - private setupShtrovSearchEffect(): void { + private setupShareTroveSearchEffect(): void { effect(() => { const provider = this.collectionProvider(); const collectionId = this.primaryCollectionId(); @@ -169,7 +169,6 @@ export class CollectionsDiscoverComponent { if (!provider || !collectionId || this.defaultSearchFiltersInitialized()) return; const collectionIri = `${this.environment.apiDomainUrl}/v2/collections/${collectionId}/`; - // TODO(ENG-9818): verify 'isContainedBy' property path against shtrove API before shipping this.actions.setDefaultFilterValue('isContainedBy', collectionIri); if (provider.requiredMetadataTemplate?.attributes?.template) { diff --git a/src/app/shared/mappers/filters/cedar-template-filter.mapper.spec.ts b/src/app/shared/mappers/filters/cedar-template-filter.mapper.spec.ts deleted file mode 100644 index c537846c9..000000000 --- a/src/app/shared/mappers/filters/cedar-template-filter.mapper.spec.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { CedarTemplate } from '@osf/features/metadata/models'; -import { FilterOperatorOption } from '@osf/shared/models/search/discaverable-filter.model'; - -import { CedarTemplateFilterMapper } from './cedar-template-filter.mapper'; - -function makeTemplate(order: string[], propertyLabels: Record): CedarTemplate { - return { - '@id': 'https://repo.metadatacenter.org/templates/test', - '@type': 'https://schema.metadatacenter.org/core/Template', - type: 'object', - title: 'Test Template', - description: '', - $schema: 'http://json-schema.org/draft-04/schema', - '@context': {} as CedarTemplate['@context'], - required: [], - properties: {}, - _ui: { order, propertyLabels, propertyDescriptions: {} }, - }; -} - -describe('CedarTemplateFilterMapper', () => { - describe('fromTemplate', () => { - it('maps ordered fields with labels to DiscoverableFilter array', () => { - const template = makeTemplate(['field1', 'field2'], { field1: 'Field One', field2: 'Field Two' }); - - const result = CedarTemplateFilterMapper.fromTemplate(template); - - expect(result).toEqual([ - { key: 'field1', label: 'Field One', operator: FilterOperatorOption.AnyOf }, - { key: 'field2', label: 'Field Two', operator: FilterOperatorOption.AnyOf }, - ]); - }); - - it('skips fields with empty labels', () => { - const template = makeTemplate(['field1', 'field2'], { field1: 'Field One', field2: '' }); - - const result = CedarTemplateFilterMapper.fromTemplate(template); - - expect(result).toHaveLength(1); - expect(result[0].key).toBe('field1'); - }); - - it('skips fields with whitespace-only labels', () => { - const template = makeTemplate(['field1', 'field2'], { field1: ' ', field2: 'Field Two' }); - - const result = CedarTemplateFilterMapper.fromTemplate(template); - - expect(result).toHaveLength(1); - expect(result[0].key).toBe('field2'); - }); - - it('skips fields absent from propertyLabels', () => { - const template = makeTemplate(['field1', 'unknown'], { field1: 'Field One' }); - - const result = CedarTemplateFilterMapper.fromTemplate(template); - - expect(result).toHaveLength(1); - expect(result[0].key).toBe('field1'); - }); - - it('preserves the order defined in _ui.order', () => { - const template = makeTemplate(['b', 'a', 'c'], { a: 'A', b: 'B', c: 'C' }); - - const result = CedarTemplateFilterMapper.fromTemplate(template); - - expect(result.map((f) => f.key)).toEqual(['b', 'a', 'c']); - }); - - it('returns an empty array when order is empty', () => { - const template = makeTemplate([], {}); - - expect(CedarTemplateFilterMapper.fromTemplate(template)).toEqual([]); - }); - - it('sets operator to AnyOf for all fields', () => { - const template = makeTemplate(['f1', 'f2'], { f1: 'F1', f2: 'F2' }); - - const result = CedarTemplateFilterMapper.fromTemplate(template); - - result.forEach((f) => expect(f.operator).toBe(FilterOperatorOption.AnyOf)); - }); - }); -}); From 3ebca0f1b208f718a8d9639d5faedfea5676ee5b Mon Sep 17 00:00:00 2001 From: Vlad0n20 Date: Wed, 13 May 2026 20:05:54 +0300 Subject: [PATCH 06/32] feat(ENG-9827): fix tests --- src/app/shared/mappers/filters/cedar-template-filter.mapper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/shared/mappers/filters/cedar-template-filter.mapper.ts b/src/app/shared/mappers/filters/cedar-template-filter.mapper.ts index 44d32e9d8..56a632e09 100644 --- a/src/app/shared/mappers/filters/cedar-template-filter.mapper.ts +++ b/src/app/shared/mappers/filters/cedar-template-filter.mapper.ts @@ -1,5 +1,5 @@ import { CedarTemplate } from '@osf/features/metadata/models'; -import { DiscoverableFilter, FilterOperatorOption } from '@osf/shared/models/search/discaverable-filter.model'; +import { DiscoverableFilter, FilterOperatorOption } from '@osf/shared/models/search/discoverable-filter.model'; export class CedarTemplateFilterMapper { static fromTemplate(template: CedarTemplate): DiscoverableFilter[] { From bec9447be19f0f0d789f354a926c4e365337ddc0 Mon Sep 17 00:00:00 2001 From: Vlad0n20 Date: Thu, 30 Apr 2026 15:01:26 +0300 Subject: [PATCH 07/32] fix(ENG-9821): fix --- .../add-to-collection.component.ts | 22 ++++---- .../collection-metadata-step.component.html | 54 ++++++++++--------- .../collection-metadata-step.component.ts | 12 ++--- .../mappers/collections/collections.mapper.ts | 2 +- .../collections/collections-json-api.model.ts | 10 ++-- .../shared/services/collections.service.ts | 21 ++++++-- src/app/shared/services/metadata.service.ts | 7 +++ 7 files changed, 77 insertions(+), 51 deletions(-) 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..ed3b3b706 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 @@ -207,7 +207,7 @@ export class AddToCollectionComponent implements CanDeactivateComponent { const payload = { collectionId: this.primaryCollectionId() || '', projectId: this.selectedProject()?.id || '', - collectionMetadata: this.isCedarMode() ? {} : this.collectionMetadataForm.value || {}, + collectionMetadata: this.collectionMetadataForm.value || {}, userId: this.currentUser()?.id || '', }; @@ -234,15 +234,17 @@ export class AddToCollectionComponent implements CanDeactivateComponent { }, }); } else { - this.customDialogService - .open(AddToCollectionConfirmationDialogComponent, { - header: 'collections.addToCollection.confirmationDialogHeader', - width: '500px', - data: { payload, project: this.selectedProject() }, - }) - .onClose.pipe( - filter((res) => !!res), - switchMap(() => this.saveCedarRecordIfNeeded()), + this.saveCedarRecordIfNeeded() + .pipe( + switchMap(() => + this.customDialogService + .open(AddToCollectionConfirmationDialogComponent, { + header: 'collections.addToCollection.confirmationDialogHeader', + width: '500px', + data: { payload, project: this.selectedProject() }, + }) + .onClose.pipe(filter((res) => !!res)) + ), takeUntilDestroyed(this.destroyRef) ) .subscribe({ diff --git a/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.html b/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.html index 0b0cd6498..8c7e779d1 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 @@ -20,16 +20,16 @@

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

[instanceObject]="cedarFormData()" > } - } @else { - @for (filterEntry of availableFilterEntries(); track filterEntry.key) { -
-

{{ filterEntry.labelKey | translate }}

+ } -

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

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

{{ filterEntry.labelKey | translate }}

+ +

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

+
} } @@ -46,6 +46,21 @@

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

+
+ @for (filterEntry of availableFilterEntries(); track filterEntry.key) { +
+ + +
+ } +
+ @if (isCedarMode()) { @if (cedarTemplate()) {
@@ -65,27 +80,16 @@

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

[label]="'common.buttons.discardChanges' | translate" (onClick)="handleDiscardChanges()" /> - +
} @else {

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

} } @else { -
- @for (filterEntry of availableFilterEntries(); track filterEntry.key) { -
- - -
- } -
-
{ const filterEntries = this.availableFilterEntries(); - if (filterEntries.length && !this.isCedarMode()) { + if (filterEntries.length) { this.buildCollectionMetadataForm(); } }); @@ -223,8 +224,7 @@ export class CollectionMetadataStepComponent { form.controls && Object.keys(form.controls).length > 0 && filterEntries.length > 0 && - !alreadyPopulated && - !this.isCedarMode() + !alreadyPopulated ) { this.populateFormFromSubmission(submission.submission); this.formPopulatedFromSubmission.set(true); @@ -233,10 +233,8 @@ export class CollectionMetadataStepComponent { effect(() => { if (!this.collectionMetadataSaved() && this.stepperActiveValue() !== AddToCollectionSteps.CollectionMetadata) { - if (!this.isCedarMode()) { - this.collectionMetadataForm().reset(); - this.formPopulatedFromSubmission.set(false); - } + this.collectionMetadataForm().reset(); + this.formPopulatedFromSubmission.set(false); } }); } diff --git a/src/app/shared/mappers/collections/collections.mapper.ts b/src/app/shared/mappers/collections/collections.mapper.ts index cd7711c26..26c717ef5 100644 --- a/src/app/shared/mappers/collections/collections.mapper.ts +++ b/src/app/shared/mappers/collections/collections.mapper.ts @@ -71,7 +71,7 @@ export class CollectionsMapper { backgroundColor: response.embeds.brand.data.attributes.background_color, } : null, - requiredMetadataTemplate: response.embeds.required_metadata_template?.data ?? null, + requiredMetadataTemplate: null, }; } diff --git a/src/app/shared/models/collections/collections-json-api.model.ts b/src/app/shared/models/collections/collections-json-api.model.ts index 9dce2537f..20a0d15d0 100644 --- a/src/app/shared/models/collections/collections-json-api.model.ts +++ b/src/app/shared/models/collections/collections-json-api.model.ts @@ -1,4 +1,3 @@ -import { CedarMetadataDataTemplateJsonApi } from '@osf/features/metadata/models'; import { CollectionSubmissionReviewState } from '@osf/shared/enums/collection-submission-review-state.enum'; import { BrandDataJsonApi } from '../brand/brand.json-api.model'; @@ -15,9 +14,6 @@ export interface CollectionProviderResponseJsonApi { brand: { data?: BrandDataJsonApi; }; - required_metadata_template?: { - data?: CedarMetadataDataTemplateJsonApi | null; - }; }; relationships: { primary_collection: { @@ -26,6 +22,12 @@ export interface CollectionProviderResponseJsonApi { type: string; }; }; + required_metadata_template?: { + data?: { + id: string; + type: string; + } | null; + }; }; } diff --git a/src/app/shared/services/collections.service.ts b/src/app/shared/services/collections.service.ts index 2f2bc8256..ba97b566e 100644 --- a/src/app/shared/services/collections.service.ts +++ b/src/app/shared/services/collections.service.ts @@ -41,6 +41,7 @@ import { ReviewActionPayloadJsonApi } from '../models/review-action/review-actio import { SetTotalSubmissions } from '../stores/collections/collections.actions'; import { JsonApiService } from './json-api.service'; +import { MetadataService } from './metadata.service'; @Injectable({ providedIn: 'root', @@ -48,6 +49,7 @@ import { JsonApiService } from './json-api.service'; export class CollectionsService { private readonly jsonApiService = inject(JsonApiService); private readonly environment = inject(ENVIRONMENT); + private readonly metadataService = inject(MetadataService); get apiUrl() { return `${this.environment.apiDomainUrl}/v2`; @@ -56,11 +58,22 @@ export class CollectionsService { private actions = createDispatchMap({ setTotalSubmissions: SetTotalSubmissions }); getCollectionProvider(collectionName: string): Observable { - const url = `${this.apiUrl}/providers/collections/${collectionName}/?embed=brand,required_metadata_template`; + const url = `${this.apiUrl}/providers/collections/${collectionName}/?embed=brand`; - return this.jsonApiService - .get>(url) - .pipe(map((response) => CollectionsMapper.fromGetCollectionProviderResponse(response.data))); + return this.jsonApiService.get>(url).pipe( + switchMap((response) => { + const provider = CollectionsMapper.fromGetCollectionProviderResponse(response.data); + const templateId = response.data.relationships.required_metadata_template?.data?.id; + + if (!templateId) { + return of(provider); + } + + return this.metadataService + .getCedarMetadataTemplateDetail(templateId) + .pipe(map((template) => ({ ...provider, requiredMetadataTemplate: template }))); + }) + ); } getCollectionDetails(collectionId: string): Observable { diff --git a/src/app/shared/services/metadata.service.ts b/src/app/shared/services/metadata.service.ts index 82c1bd357..0d0df1364 100644 --- a/src/app/shared/services/metadata.service.ts +++ b/src/app/shared/services/metadata.service.ts @@ -6,6 +6,7 @@ import { inject, Injectable } from '@angular/core'; import { ENVIRONMENT } from '@core/provider/environment.provider'; import { CedarRecordsMapper, MetadataMapper, RorMapper } from '@osf/features/metadata/mappers'; import { + CedarMetadataDataTemplateJsonApi, CedarMetadataRecord, CedarMetadataRecordJsonApi, CedarMetadataTemplateJsonApi, @@ -102,6 +103,12 @@ export class MetadataService { ); } + getCedarMetadataTemplateDetail(templateId: string): Observable { + return this.jsonApiService + .get<{ data: CedarMetadataDataTemplateJsonApi }>(`${this.apiDomainUrl}/_/cedar_metadata_templates/${templateId}/`) + .pipe(map((response) => response.data)); + } + getMetadataCedarRecords( resourceId: string, resourceType: ResourceType, From 694534e4953e5f6d305793fd4e3a5602f2a52e6f Mon Sep 17 00:00:00 2001 From: Vlad0n20 Date: Tue, 12 May 2026 21:46:07 +0300 Subject: [PATCH 08/32] feat(ENG-9827): remove frontend cedar pre-save from collection submission flow --- .../add-to-collection.component.html | 4 - .../add-to-collection.component.spec.ts | 178 +++++++++++++----- .../add-to-collection.component.ts | 79 ++------ .../collection-metadata-step.component.html | 65 ++----- ...collection-metadata-step.component.spec.ts | 99 +--------- .../collection-metadata-step.component.ts | 93 +-------- .../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 ++++++ 11 files changed, 428 insertions(+), 361 deletions(-) create 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 d76299fba..41cf077d5 100644 --- a/src/app/features/collections/components/add-to-collection/add-to-collection.component.html +++ b/src/app/features/collections/components/add-to-collection/add-to-collection.component.html @@ -48,11 +48,7 @@

{{ collectionProvider()? [targetStepValue]="AddToCollectionSteps.CollectionMetadata" [isDisabled]="isCollectionMetadataDisabled()" [primaryCollectionId]="primaryCollectionId()" - [isCedarMode]="isCedarMode()" - [cedarTemplate]="requiredMetadataTemplate()" - [existingCedarRecord]="existingCedarRecord()" (metadataSaved)="handleCollectionMetadataSaved($event)" - (cedarDataSaved)="handleCedarDataSaved($event)" (stepChange)="handleChangeStep($event)" /> diff --git a/src/app/features/collections/components/add-to-collection/add-to-collection.component.spec.ts b/src/app/features/collections/components/add-to-collection/add-to-collection.component.spec.ts index b7c9645b7..b88520379 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 { Subject } from 'rxjs'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormGroup } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; @@ -10,10 +12,13 @@ import { ProjectContributorsStepComponent } from '@osf/features/collections/comp import { ProjectMetadataStepComponent } from '@osf/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component'; import { SelectProjectStepComponent } from '@osf/features/collections/components/add-to-collection/select-project-step/select-project-step.component'; import { AddToCollectionSteps } from '@osf/features/collections/enums'; -import { CedarRecordDataBinding } from '@osf/features/metadata/models'; -import { MetadataSelectors } from '@osf/features/metadata/store'; +import { RemoveFromCollectionDialogResult } from '@osf/features/collections/models/remove-from-collection-dialog-result.model'; +import { AddToCollectionSelectors } from '@osf/features/collections/store/add-to-collection'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; +import { CollectionSubmissionReviewState } from '@osf/shared/enums/collection-submission-review-state.enum'; +import { CollectionProjectSubmission } from '@osf/shared/models/collections/collections.model'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.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'; @@ -21,27 +26,38 @@ import { ProjectsSelectors } from '@shared/stores/projects/projects.selectors'; 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 { MOCK_COLLECTION_SUBMISSION_WITH_GUID } from '@testing/mocks/submission.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; -import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock'; +import { + CustomDialogServiceMockBuilder, + CustomDialogServiceMockType, +} from '@testing/providers/custom-dialog-provider.mock'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; -import { RouterMockBuilder } from '@testing/providers/router-provider.mock'; +import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock'; import { provideMockStore } from '@testing/providers/store-provider.mock'; import { AddToCollectionComponent } from './add-to-collection.component'; +interface SetupOptions { + projectId?: string | null; + currentSubmission?: CollectionProjectSubmission | null; +} + describe('AddToCollectionComponent', () => { - let component: AddToCollectionComponent; - let fixture: ComponentFixture; - let mockRouter: ReturnType; - let mockActivatedRoute: ReturnType; - let mockCustomDialogService: ReturnType; + function setup(options: SetupOptions = {}): { + fixture: ComponentFixture; + component: AddToCollectionComponent; + mockRouter: RouterMockType; + mockCustomDialogService: CustomDialogServiceMockType; + } { + const { projectId = null, currentSubmission = null } = options; - const mockCollectionProvider = MOCK_PROVIDER; + const mockRouter = RouterMockBuilder.create().build(); + const routeParams: Record = { providerId: 'provider-1' }; + if (projectId) routeParams['id'] = projectId; - beforeEach(() => { - mockRouter = RouterMockBuilder.create().build(); - mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams({ id: null }).build(); - mockCustomDialogService = CustomDialogServiceMockBuilder.create().build(); + const mockActivatedRoute = ActivatedRouteMockBuilder.create().withParams(routeParams).build(); + const mockCustomDialogService = CustomDialogServiceMockBuilder.create().build(); TestBed.configureTestingModule({ imports: [ @@ -60,29 +76,33 @@ describe('AddToCollectionComponent', () => { MockProvider(Router, mockRouter), MockProvider(CustomDialogService, mockCustomDialogService), MockProvider(ToastService), + MockProvider(LoaderService), provideMockStore({ signals: [ { selector: CollectionsSelectors.getCollectionProviderLoading, value: false }, - { selector: CollectionsSelectors.getCollectionProvider, value: mockCollectionProvider }, - { selector: CollectionsSelectors.getRequiredMetadataTemplate, value: null }, + { selector: CollectionsSelectors.getCollectionProvider, value: MOCK_PROVIDER }, { selector: ProjectsSelectors.getSelectedProject, value: MOCK_PROJECT }, { selector: UserSelectors.getCurrentUser, value: MOCK_USER }, - { selector: MetadataSelectors.getCedarRecords, value: [] }, + { selector: AddToCollectionSelectors.getCurrentCollectionSubmission, value: currentSubmission }, ], }), ], }); - fixture = TestBed.createComponent(AddToCollectionComponent); - component = fixture.componentInstance; + const fixture = TestBed.createComponent(AddToCollectionComponent); + const component = fixture.componentInstance; fixture.detectChanges(); - }); + + return { fixture, component, mockRouter, mockCustomDialogService }; + } it('should create', () => { + const { component } = setup(); expect(component).toBeTruthy(); }); it('should initialize with default values', () => { + const { component } = setup(); expect(component.stepperActiveValue()).toBe(AddToCollectionSteps.SelectProject); expect(component.projectMetadataSaved()).toBe(false); expect(component.projectContributorsSaved()).toBe(false); @@ -91,74 +111,146 @@ describe('AddToCollectionComponent', () => { }); it('should handle project selection', () => { + const { component } = setup(); component.handleProjectSelected(); - expect(component.projectContributorsSaved()).toBe(false); expect(component.projectMetadataSaved()).toBe(false); expect(component.allowNavigation()).toBe(false); }); it('should handle step change', () => { - const newStep = AddToCollectionSteps.ProjectMetadata; - component.handleChangeStep(newStep); - - expect(component.stepperActiveValue()).toBe(newStep); + const { component } = setup(); + component.handleChangeStep(AddToCollectionSteps.ProjectMetadata); + expect(component.stepperActiveValue()).toBe(AddToCollectionSteps.ProjectMetadata); }); 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); expect(component.projectContributorsSaved()).toBe(true); }); it('should handle collection metadata saved', () => { + const { component } = setup(); 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 handle cedar data saved', () => { - const mockCedarData: CedarRecordDataBinding = { - data: {} as CedarRecordDataBinding['data'], - id: 'template-123', - isPublished: false, - }; - component.handleCedarDataSaved(mockCedarData); - - expect(component.pendingCedarData()).toEqual(mockCedarData); - expect(component.collectionMetadataSaved()).toBe(true); - expect(component.stepperActiveValue()).toBe(AddToCollectionSteps.Complete); - }); - it('should have actions defined', () => { + const { component } = setup(); expect(component.actions).toBeDefined(); expect(component.actions.getCollectionProvider).toBeDefined(); expect(component.actions.clearAddToCollectionState).toBeDefined(); }); - it('should handle loading state', () => { + it('should report provider loading state', () => { + const { component } = setup(); expect(component.isProviderLoading()).toBe(false); }); it('should have collection provider data', () => { - expect(component.collectionProvider()).toEqual(mockCollectionProvider); + const { component } = setup(); + expect(component.collectionProvider()).toEqual(MOCK_PROVIDER); }); it('should have selected project data', () => { + const { component } = setup(); expect(component.selectedProject()).toEqual(MOCK_PROJECT); }); it('should have current user data', () => { + const { component } = setup(); expect(component.currentUser()).toEqual(MOCK_USER); }); + + it('should not be in edit mode when no project id in route', () => { + const { component } = setup(); + expect(component.isEditMode()).toBe(false); + }); + + it('should be in edit mode when project id is present in route', () => { + const { component } = setup({ projectId: 'project-1' }); + expect(component.isEditMode()).toBe(true); + }); + + it('should not show remove button in new mode', () => { + const { component } = setup(); + expect(component.showRemoveButton()).toBe(false); + }); + + it('should not show remove button in edit mode with non-accepted state', () => { + const currentSubmission: CollectionProjectSubmission = { + submission: { ...MOCK_COLLECTION_SUBMISSION_WITH_GUID, reviewsState: CollectionSubmissionReviewState.Pending }, + project: MOCK_PROJECT, + }; + const { component } = setup({ projectId: 'project-1', currentSubmission }); + expect(component.showRemoveButton()).toBe(false); + }); + + it('should show remove button in edit mode with accepted state', () => { + const currentSubmission: CollectionProjectSubmission = { + submission: { ...MOCK_COLLECTION_SUBMISSION_WITH_GUID, reviewsState: CollectionSubmissionReviewState.Accepted }, + project: MOCK_PROJECT, + }; + const { component } = setup({ projectId: 'project-1', currentSubmission }); + expect(component.showRemoveButton()).toBe(true); + }); + + it('should allow deactivation when allowNavigation is true', () => { + const { component } = setup(); + component.allowNavigation.set(true); + expect(component.canDeactivate()).toBe(true); + }); + + it('should block deactivation when there are unsaved changes', () => { + const { component } = setup(); + expect(component.canDeactivate()).toBe(false); + }); + + it('should navigate after adding to collection in edit mode', () => { + const { component, mockRouter } = setup({ projectId: 'project-1' }); + + component.handleAddToCollection(); + + expect(mockRouter.navigate).toHaveBeenCalledWith([MOCK_PROJECT.id, 'overview']); + expect(component.allowNavigation()).toBe(true); + }); + + it('should open confirmation dialog and navigate after confirmation in new mode', () => { + const { component, mockRouter, mockCustomDialogService } = setup(); + + const onClose = new Subject(); + mockCustomDialogService.open.mockReturnValue({ onClose } as any); + + component.handleAddToCollection(); + onClose.next(true); + + expect(mockCustomDialogService.open).toHaveBeenCalled(); + expect(mockRouter.navigate).toHaveBeenCalledWith([MOCK_PROJECT.id, 'overview']); + expect(component.allowNavigation()).toBe(true); + }); + + it('should navigate after successful remove from collection', () => { + const { component, mockRouter, mockCustomDialogService } = setup({ projectId: 'project-1' }); + + const onClose = new Subject(); + mockCustomDialogService.open.mockReturnValue({ onClose } as any); + + component.handleRemoveFromCollection(); + onClose.next({ confirmed: true, comment: '' }); + + expect(mockCustomDialogService.open).toHaveBeenCalled(); + expect(mockRouter.navigate).toHaveBeenCalledWith([MOCK_PROJECT.id, 'overview']); + expect(component.allowNavigation()).toBe(true); + }); }); 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 ed3b3b706..307bab0e8 100644 --- a/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts +++ b/src/app/features/collections/components/add-to-collection/add-to-collection.component.ts @@ -23,18 +23,9 @@ import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { FormGroup } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; -import { ENVIRONMENT } from '@core/provider/environment.provider'; import { UserSelectors } from '@core/store/user'; -import { CedarMetadataRecordData, CedarRecordDataBinding } from '@osf/features/metadata/models'; -import { - CreateCedarMetadataRecord, - GetCedarMetadataRecords, - MetadataSelectors, - UpdateCedarMetadataRecord, -} from '@osf/features/metadata/store'; import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component'; import { CollectionSubmissionReviewState } from '@osf/shared/enums/collection-submission-review-state.enum'; -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { CanDeactivateComponent } from '@osf/shared/models/can-deactivate.interface'; import { BrandService } from '@osf/shared/services/brand.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; @@ -90,7 +81,6 @@ export class AddToCollectionComponent implements CanDeactivateComponent { private readonly headerStyleHelper = inject(HeaderStyleService); private readonly platformId = inject(PLATFORM_ID); private readonly isBrowser = isPlatformBrowser(this.platformId); - private readonly environment = inject(ENVIRONMENT); readonly selectedProjectId = toSignal( this.route.params.pipe(map((params) => params['id'])) ?? of(null) @@ -102,18 +92,15 @@ export class AddToCollectionComponent implements CanDeactivateComponent { isProviderLoading = select(CollectionsSelectors.getCollectionProviderLoading); collectionProvider = select(CollectionsSelectors.getCollectionProvider); - requiredMetadataTemplate = select(CollectionsSelectors.getRequiredMetadataTemplate); selectedProject = select(ProjectsSelectors.getSelectedProject); currentUser = select(UserSelectors.getCurrentUser); currentCollectionSubmission = select(AddToCollectionSelectors.getCurrentCollectionSubmission); - cedarRecords = select(MetadataSelectors.getCedarRecords); providerId = signal(''); allowNavigation = signal(false); projectMetadataSaved = signal(false); projectContributorsSaved = signal(false); collectionMetadataSaved = signal(false); - pendingCedarData = signal(null); stepperActiveValue = signal(AddToCollectionSteps.SelectProject); primaryCollectionId = computed(() => this.collectionProvider()?.primaryCollection?.id); @@ -123,13 +110,6 @@ export class AddToCollectionComponent implements CanDeactivateComponent { isCollectionMetadataDisabled = computed( () => !this.selectedProject() || !this.projectMetadataSaved() || !this.projectContributorsSaved() ); - isCedarMode = computed(() => this.environment.collectionSubmissionWithCedar && !!this.requiredMetadataTemplate()); - existingCedarRecord = computed(() => { - const records = this.cedarRecords(); - const templateId = this.requiredMetadataTemplate()?.id; - if (!records?.length || !templateId) return null; - return records.find((r) => r.relationships?.template?.data?.id === templateId) ?? null; - }); readonly actions = createDispatchMap({ getCollectionProvider: GetCollectionProvider, @@ -138,9 +118,6 @@ export class AddToCollectionComponent implements CanDeactivateComponent { deleteCollectionSubmission: RemoveCollectionSubmission, setSelectedProject: SetSelectedProject, getCurrentCollectionSubmission: GetCurrentCollectionSubmission, - getCedarRecords: GetCedarMetadataRecords, - createCedarRecord: CreateCedarMetadataRecord, - updateCedarRecord: UpdateCedarMetadataRecord, }); showRemoveButton = computed( @@ -197,12 +174,6 @@ 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() || '', @@ -211,15 +182,12 @@ export class AddToCollectionComponent implements CanDeactivateComponent { userId: this.currentUser()?.id || '', }; - const isEditMode = this.isEditMode(); - - if (isEditMode) { + if (this.isEditMode()) { this.loaderService.show(); this.actions .updateCollectionSubmission(payload) .pipe( - switchMap(() => this.saveCedarRecordIfNeeded()), finalize(() => this.loaderService.hide()), takeUntilDestroyed(this.destroyRef) ) @@ -234,17 +202,14 @@ export class AddToCollectionComponent implements CanDeactivateComponent { }, }); } else { - this.saveCedarRecordIfNeeded() - .pipe( - switchMap(() => - this.customDialogService - .open(AddToCollectionConfirmationDialogComponent, { - header: 'collections.addToCollection.confirmationDialogHeader', - width: '500px', - data: { payload, project: this.selectedProject() }, - }) - .onClose.pipe(filter((res) => !!res)) - ), + this.customDialogService + .open(AddToCollectionConfirmationDialogComponent, { + header: 'collections.addToCollection.confirmationDialogHeader', + width: '500px', + data: { payload, project: this.selectedProject() }, + }) + .onClose.pipe( + filter((res) => !!res), takeUntilDestroyed(this.destroyRef) ) .subscribe({ @@ -280,35 +245,21 @@ export class AddToCollectionComponent implements CanDeactivateComponent { collectionId, comment: res?.comment || '', }; - + this.loaderService.show(); return this.actions.deleteCollectionSubmission(payload); }), + finalize(() => this.loaderService.hide()), takeUntilDestroyed(this.destroyRef) ) .subscribe({ next: () => { this.toastService.showSuccess('collections.removeDialog.success'); - this.loaderService.show(); this.allowNavigation.set(true); this.router.navigate([projectId, 'overview']); }, }); } - private saveCedarRecordIfNeeded(): Observable { - if (!this.isCedarMode()) return of(null); - - const cedarData = this.pendingCedarData(); - const projectId = this.selectedProject()?.id; - const templateId = this.requiredMetadataTemplate()?.id; - if (!cedarData || !projectId || !templateId) return of(null); - - const existingId = this.existingCedarRecord()?.id; - return existingId - ? this.actions.updateCedarRecord(cedarData, existingId, projectId, ResourceType.Project) - : this.actions.createCedarRecord(cedarData, projectId, ResourceType.Project); - } - private initializeProvider(): void { const id = this.route.snapshot.paramMap.get('providerId'); if (!id) { @@ -347,14 +298,6 @@ export class AddToCollectionComponent implements CanDeactivateComponent { this.actions.setSelectedProject(submission.project); } }); - - effect(() => { - const projectId = this.selectedProjectId(); - const isCedar = this.isCedarMode(); - if (isCedar && projectId) { - this.actions.getCedarRecords(projectId, ResourceType.Project); - } - }); } private setupCleanup() { diff --git a/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.html b/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.html index 8c7e779d1..f10094962 100644 --- a/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.html +++ b/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.html @@ -11,17 +11,6 @@

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

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

{{ filterEntry.labelKey | translate }}

@@ -61,48 +50,18 @@

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

} - @if (isCedarMode()) { - @if (cedarTemplate()) { -
- -
- -
- - -
- } @else { -

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

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

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

@if (license.requiredFields.length) {
-
- - + + +
+ } + @if (license.requiredFields.includes('copyrightHolders')) { + -
- + }

} diff --git a/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.ts b/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.ts index 878893086..4f38ab5dc 100644 --- a/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.ts +++ b/src/app/features/collections/components/add-to-collection/project-metadata-step/project-metadata-step.component.ts @@ -238,7 +238,7 @@ export class ProjectMetadataStepComponent { this.formService.updateLicenseValidators(this.projectMetadataForm, license); }); } - this.populateFormFromProject(); + untracked(() => this.populateFormFromProject()); }); effect(() => { diff --git a/src/app/features/collections/services/project-metadata-form.service.ts b/src/app/features/collections/services/project-metadata-form.service.ts index 84a563259..a2a19527a 100644 --- a/src/app/features/collections/services/project-metadata-form.service.ts +++ b/src/app/features/collections/services/project-metadata-form.service.ts @@ -45,10 +45,10 @@ export class ProjectMetadataFormService { const yearControl = form.get(ProjectMetadataFormControls.LicenseYear); const copyrightHoldersControl = form.get(ProjectMetadataFormControls.CopyrightHolders); - const validators = license.requiredFields.length ? [CustomValidators.requiredTrimmed()] : []; - - yearControl?.setValidators(validators); - copyrightHoldersControl?.setValidators(validators); + yearControl?.setValidators(license.requiredFields.includes('year') ? [CustomValidators.requiredTrimmed()] : []); + copyrightHoldersControl?.setValidators( + license.requiredFields.includes('copyrightHolders') ? [CustomValidators.requiredTrimmed()] : [] + ); yearControl?.updateValueAndValidity(); copyrightHoldersControl?.updateValueAndValidity(); From 876ee5fb4df5cccb42ed4bf420e3e11ef55a1083 Mon Sep 17 00:00:00 2001 From: Yuhuai Liu Date: Wed, 3 Jun 2026 03:48:54 -0400 Subject: [PATCH 16/32] feat(es2): Fix search --- .../collections-discover/collections-discover.component.ts | 2 -- .../shared/components/search-input/search-input.component.ts | 1 - 2 files changed, 3 deletions(-) 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 8eefc8d95..138ff5465 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 @@ -179,8 +179,6 @@ export class CollectionsDiscoverComponent { private setupCollectionDetailsEffect(): void { effect(() => { - if (this.useShareTroveSearch()) return; - const collectionId = this.primaryCollectionId(); if (collectionId) { this.actions.getCollectionDetails(collectionId); diff --git a/src/app/shared/components/search-input/search-input.component.ts b/src/app/shared/components/search-input/search-input.component.ts index 809ecb62b..fea69255b 100644 --- a/src/app/shared/components/search-input/search-input.component.ts +++ b/src/app/shared/components/search-input/search-input.component.ts @@ -29,7 +29,6 @@ export class SearchInputComponent { if (!searchValue || !searchValue?.trim()?.length) { return; } - this.triggerSearch.emit(searchValue); } } From 42eb015bf18c1a20429cec3b1d889077990ad22f Mon Sep 17 00:00:00 2001 From: Yuhuai Liu Date: Wed, 3 Jun 2026 15:30:52 -0400 Subject: [PATCH 17/32] feat(es2): fix search page again --- .../collections-discover/collections-discover.component.ts | 4 ++-- src/app/shared/mappers/collections/collections.mapper.ts | 1 + .../shared/models/collections/collections-json-api.model.ts | 3 +++ src/app/shared/models/collections/collections.model.ts | 1 + 4 files changed, 7 insertions(+), 2 deletions(-) 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 138ff5465..62c60061d 100644 --- a/src/app/features/collections/components/collections-discover/collections-discover.component.ts +++ b/src/app/features/collections/components/collections-discover/collections-discover.component.ts @@ -161,10 +161,10 @@ export class CollectionsDiscoverComponent { private setupShareTroveSearchEffect(): void { effect(() => { const provider = this.collectionProvider(); - const collectionIri = this.collectionDetails()?.iri; + const collectionIri = provider?.iri; if (!this.useShareTroveSearch() || !provider || !collectionIri || this.defaultSearchFiltersInitialized()) return; - this.actions.setDefaultFilterValue('isContainedBy', collectionIri); + this.actions.setDefaultFilterValue('isPartOfCollection', collectionIri); if (provider.requiredMetadataTemplate?.attributes?.template) { const extraFilters = CedarTemplateFilterMapper.fromTemplate( diff --git a/src/app/shared/mappers/collections/collections.mapper.ts b/src/app/shared/mappers/collections/collections.mapper.ts index a13c22e91..165e9544f 100644 --- a/src/app/shared/mappers/collections/collections.mapper.ts +++ b/src/app/shared/mappers/collections/collections.mapper.ts @@ -50,6 +50,7 @@ export class CollectionsMapper { favicon: response.attributes.assets.favicon, } : {}, + iri: response.links?.iri, shareSource: response.attributes.share_source, sharePublishType: response.attributes.share_publish_type, permissions: response.attributes.permissions, 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 13e031ca0..885295386 100644 --- a/src/app/shared/models/collections/collections-json-api.model.ts +++ b/src/app/shared/models/collections/collections-json-api.model.ts @@ -10,6 +10,9 @@ import { UserDataErrorResponseJsonApi } from '../user/user-json-api.model'; export interface CollectionProviderResponseJsonApi { id: string; type: string; + links?: { + iri?: string; + }; attributes: CollectionsProviderAttributesJsonApi; embeds: { brand: { diff --git a/src/app/shared/models/collections/collections.model.ts b/src/app/shared/models/collections/collections.model.ts index 8fee884d5..e2b6bdf0b 100644 --- a/src/app/shared/models/collections/collections.model.ts +++ b/src/app/shared/models/collections/collections.model.ts @@ -8,6 +8,7 @@ import { ProjectModel } from '../projects/projects.model'; import { BaseProviderModel } from '../provider/provider.model'; export interface CollectionProvider extends BaseProviderModel { + iri?: string; assets: { style?: string; squareColorTransparent?: string; From 50c598465aec64dc86ed2454ee39ed6baeb18930 Mon Sep 17 00:00:00 2001 From: Yuhuai Liu Date: Wed, 3 Jun 2026 15:41:30 -0400 Subject: [PATCH 18/32] feat(es2): Fix tests --- .../collections-discover.component.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 c9da867db..a3ca35636 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 @@ -33,6 +33,7 @@ const MOCK_COLLECTION_IRI = 'http://localhost:8000/v2/collections/collection-1/' const MOCK_COLLECTION_PROVIDER = { ...MOCK_PROVIDER, + iri: MOCK_COLLECTION_IRI, primaryCollection: { id: 'collection-1', type: 'collections' }, requiredMetadataTemplate: null, }; @@ -249,7 +250,7 @@ describe('CollectionsDiscoverComponent', () => { ) as SetDefaultFilterValue; expect(setDefaultFilter).toBeDefined(); - expect(setDefaultFilter.filterKey).toBe('isContainedBy'); + expect(setDefaultFilter.filterKey).toBe('isPartOfCollection'); expect(setDefaultFilter.value).toBe(MOCK_COLLECTION_IRI); }); From 70a387150f92f5b3876d87beae849ff0b0abf34c Mon Sep 17 00:00:00 2001 From: Yuhuai Liu Date: Thu, 4 Jun 2026 12:34:44 -0400 Subject: [PATCH 19/32] feat(es2): Fix collection discover page facet --- .../collections-discover.component.spec.ts | 16 +- .../models/cedar-metadata-template.model.ts | 16 ++ .../generic-filter.component.html | 7 +- .../search-filters.component.spec.ts | 34 +++ .../search-filters.component.ts | 2 +- .../cedar-template-filter.mapper.spec.ts | 144 +++++++++++++ .../filters/cedar-template-filter.mapper.ts | 47 +++- .../search/discoverable-filter.model.ts | 3 +- .../global-search/global-search.state.spec.ts | 203 ++++++++++++++++++ .../global-search/global-search.state.ts | 41 +++- 10 files changed, 493 insertions(+), 20 deletions(-) create mode 100644 src/app/shared/mappers/filters/cedar-template-filter.mapper.spec.ts create mode 100644 src/app/shared/stores/global-search/global-search.state.spec.ts 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 a3ca35636..0d590b06f 100644 --- a/src/app/features/collections/components/collections-discover/collections-discover.component.spec.ts +++ b/src/app/features/collections/components/collections-discover/collections-discover.component.spec.ts @@ -79,7 +79,19 @@ const MOCK_COLLECTION_PROVIDER_WITH_TEMPLATE = { $schema: 'http://json-schema.org/draft-04/schema', '@context': {} as never, required: [], - properties: {}, + properties: { + '@context': { + properties: { + field1: { enum: ['https://schema.metadatacenter.org/properties/test-field-uuid'] }, + }, + }, + field1: { + '@type': 'https://schema.metadatacenter.org/core/TemplateField', + _valueConstraints: { + literals: [{ label: 'Option A' }, { label: 'Option B' }], + }, + }, + }, _ui: { order: ['field1'], propertyLabels: { field1: 'Field One' }, @@ -274,6 +286,8 @@ describe('CollectionsDiscoverComponent', () => { expect(setExtraFilters.filters).toHaveLength(1); expect(setExtraFilters.filters[0].key).toBe('field1'); expect(setExtraFilters.filters[0].label).toBe('Field One'); + expect(setExtraFilters.filters[0].cedarPropertyIri).toBe('test-field-uuid'); + expect(setExtraFilters.filters[0].options).toHaveLength(2); }); it('should render GlobalSearchComponent when filters are initialized', () => { diff --git a/src/app/features/metadata/models/cedar-metadata-template.model.ts b/src/app/features/metadata/models/cedar-metadata-template.model.ts index e75886006..fb2969382 100644 --- a/src/app/features/metadata/models/cedar-metadata-template.model.ts +++ b/src/app/features/metadata/models/cedar-metadata-template.model.ts @@ -10,6 +10,22 @@ export interface CedarMetadataDataTemplateJsonApi { }; } +export const CEDAR_TEMPLATE_FIELD_TYPE = 'https://schema.metadatacenter.org/core/TemplateField'; +export const CEDAR_PROPERTIES_BASE_IRI = 'https://schema.metadatacenter.org/properties/'; + +export interface CedarTemplateField { + '@type': string; + _valueConstraints?: { + literals?: { label: string }[]; + multipleChoice?: boolean; + requiredValue?: boolean; + }; +} + +export interface CedarTemplateContextSchema { + properties: Record; +} + export interface CedarTemplate { '@id': string; '@type': string; diff --git a/src/app/shared/components/generic-filter/generic-filter.component.html b/src/app/shared/components/generic-filter/generic-filter.component.html index 4288d45f2..f88ac85c8 100644 --- a/src/app/shared/components/generic-filter/generic-filter.component.html +++ b/src/app/shared/components/generic-filter/generic-filter.component.html @@ -27,7 +27,12 @@ (onLazyLoad)="loadMoreItems($event)" > -

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

+

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

} diff --git a/src/app/shared/components/search-filters/search-filters.component.spec.ts b/src/app/shared/components/search-filters/search-filters.component.spec.ts index e28d4bdca..96ee5e76a 100644 --- a/src/app/shared/components/search-filters/search-filters.component.spec.ts +++ b/src/app/shared/components/search-filters/search-filters.component.spec.ts @@ -118,6 +118,40 @@ describe('SearchFiltersComponent', () => { expect(visibleFilters.length).toBe(3); }); + it('should show CEDAR filters that have options but no resultCount', () => { + const cedarFilter: DiscoverableFilter = { + key: 'School Type', + label: 'School Type', + operator: FilterOperatorOption.AnyOf, + cedarPropertyIri: 'uuid-school-type', + options: [ + { label: 'High School', value: 'High School', cardSearchResultCount: null }, + { label: 'Middle School', value: 'Middle School', cardSearchResultCount: null }, + ], + }; + + fixture.componentRef.setInput('filters', [cedarFilter]); + fixture.detectChanges(); + + expect(component.visibleFilters()).toHaveLength(1); + expect(component.visibleFilters()[0].key).toBe('School Type'); + }); + + it('should still hide a filter with resultCount 0 and no options', () => { + const zeroCountFilter: DiscoverableFilter = { + key: 'emptyFilter', + label: 'Empty', + operator: FilterOperatorOption.AnyOf, + resultCount: 0, + options: [], + }; + + fixture.componentRef.setInput('filters', [zeroCountFilter]); + fixture.detectChanges(); + + expect(component.visibleFilters()).toHaveLength(0); + }); + it('should compute splitFilters correctly', () => { fixture.componentRef.setInput('filters', mockFilters); fixture.detectChanges(); diff --git a/src/app/shared/components/search-filters/search-filters.component.ts b/src/app/shared/components/search-filters/search-filters.component.ts index c87370fc3..f7f8991f4 100644 --- a/src/app/shared/components/search-filters/search-filters.component.ts +++ b/src/app/shared/components/search-filters/search-filters.component.ts @@ -75,7 +75,7 @@ export class SearchFiltersComponent { return this.filters().filter((filter) => { if (!filter || !filter.key) return false; - return Boolean((filter.resultCount && filter.resultCount > 0) || (filter.options && filter.options.length > 0)); + return filter.resultCount === undefined || filter.resultCount > 0 || (filter.options?.length ?? 0) > 0; }); }); diff --git a/src/app/shared/mappers/filters/cedar-template-filter.mapper.spec.ts b/src/app/shared/mappers/filters/cedar-template-filter.mapper.spec.ts new file mode 100644 index 000000000..b37663180 --- /dev/null +++ b/src/app/shared/mappers/filters/cedar-template-filter.mapper.spec.ts @@ -0,0 +1,144 @@ +import { CEDAR_TEMPLATE_FIELD_TYPE, CedarTemplate } from '@osf/features/metadata/models'; +import { FilterOperatorOption } from '@osf/shared/models/search/discoverable-filter.model'; + +import { CedarTemplateFilterMapper } from './cedar-template-filter.mapper'; + +const CEDAR_BASE = 'https://schema.metadatacenter.org/properties/'; + +function makeTemplate(overrides: Partial = {}): CedarTemplate { + return { + '@id': 'https://repo.metadatacenter.org/templates/test', + '@type': 'https://schema.metadatacenter.org/core/Template', + type: 'object', + title: 'Test', + description: '', + $schema: 'http://json-schema.org/draft-04/schema', + '@context': {} as never, + required: [], + properties: { + '@context': { + properties: { + 'School Type': { enum: [`${CEDAR_BASE}uuid-school-type`] }, + 'Study Design': { enum: [`${CEDAR_BASE}uuid-study-design`] }, + About: { enum: [`${CEDAR_BASE}uuid-about`] }, + }, + }, + 'School Type': { + '@type': CEDAR_TEMPLATE_FIELD_TYPE, + _valueConstraints: { + literals: [{ label: 'High School' }, { label: 'Middle School' }], + }, + }, + 'Study Design': { + '@type': CEDAR_TEMPLATE_FIELD_TYPE, + _valueConstraints: { + literals: [{ label: 'Intervention' }, { label: 'Correlational' }], + }, + }, + About: { + '@type': 'https://schema.metadatacenter.org/core/StaticTemplateField', + _ui: { inputType: 'richtext' }, + }, + }, + _ui: { + order: ['School Type', 'Study Design', 'About'], + propertyLabels: { 'School Type': 'School Type', 'Study Design': 'Study Design', About: 'About' }, + propertyDescriptions: {}, + }, + ...overrides, + }; +} + +describe('CedarTemplateFilterMapper', () => { + describe('fromTemplate', () => { + it('should only include TemplateField entries with literals', () => { + const filters = CedarTemplateFilterMapper.fromTemplate(makeTemplate()); + + expect(filters).toHaveLength(2); + expect(filters.map((f) => f.key)).toEqual(['School Type', 'Study Design']); + }); + + it('should skip StaticTemplateField entries', () => { + const filters = CedarTemplateFilterMapper.fromTemplate(makeTemplate()); + + expect(filters.some((f) => f.key === 'About')).toBe(false); + }); + + it('should pre-populate options from _valueConstraints.literals', () => { + const filters = CedarTemplateFilterMapper.fromTemplate(makeTemplate()); + const schoolType = filters.find((f) => f.key === 'School Type')!; + + expect(schoolType.options).toHaveLength(2); + expect(schoolType.options![0]).toEqual({ + label: 'High School', + value: 'High School', + cardSearchResultCount: null, + }); + expect(schoolType.options![1]).toEqual({ + label: 'Middle School', + value: 'Middle School', + cardSearchResultCount: null, + }); + }); + + it('should set cardSearchResultCount to null for all options', () => { + const filters = CedarTemplateFilterMapper.fromTemplate(makeTemplate()); + + filters.forEach((f) => { + f.options?.forEach((opt) => { + expect(opt.cardSearchResultCount).toBeNull(); + }); + }); + }); + + it('should set cedarPropertyIri to the UUID from the context IRI', () => { + const filters = CedarTemplateFilterMapper.fromTemplate(makeTemplate()); + const schoolType = filters.find((f) => f.key === 'School Type')!; + const studyDesign = filters.find((f) => f.key === 'Study Design')!; + + expect(schoolType.cedarPropertyIri).toBe('uuid-school-type'); + expect(studyDesign.cedarPropertyIri).toBe('uuid-study-design'); + }); + + it('should set operator to AnyOf', () => { + const filters = CedarTemplateFilterMapper.fromTemplate(makeTemplate()); + + filters.forEach((f) => { + expect(f.operator).toBe(FilterOperatorOption.AnyOf); + }); + }); + + it('should use propertyLabels for the filter label', () => { + const filters = CedarTemplateFilterMapper.fromTemplate(makeTemplate()); + + expect(filters[0].label).toBe('School Type'); + expect(filters[1].label).toBe('Study Design'); + }); + + it('should skip fields with no literals', () => { + const template = makeTemplate(); + (template.properties['School Type'] as any)._valueConstraints = { literals: [] }; + + const filters = CedarTemplateFilterMapper.fromTemplate(template); + + expect(filters.some((f) => f.key === 'School Type')).toBe(false); + }); + + it('should skip fields with an empty label', () => { + const template = makeTemplate(); + template._ui.propertyLabels['School Type'] = ' '; + + const filters = CedarTemplateFilterMapper.fromTemplate(template); + + expect(filters.some((f) => f.key === 'School Type')).toBe(false); + }); + + it('should return an empty array when no filterable fields exist', () => { + const template = makeTemplate({ + _ui: { order: ['About'], propertyLabels: { About: 'About' }, propertyDescriptions: {} }, + }); + + expect(CedarTemplateFilterMapper.fromTemplate(template)).toEqual([]); + }); + }); +}); diff --git a/src/app/shared/mappers/filters/cedar-template-filter.mapper.ts b/src/app/shared/mappers/filters/cedar-template-filter.mapper.ts index 56a632e09..de0d473f3 100644 --- a/src/app/shared/mappers/filters/cedar-template-filter.mapper.ts +++ b/src/app/shared/mappers/filters/cedar-template-filter.mapper.ts @@ -1,16 +1,47 @@ -import { CedarTemplate } from '@osf/features/metadata/models'; -import { DiscoverableFilter, FilterOperatorOption } from '@osf/shared/models/search/discoverable-filter.model'; +import { + CEDAR_PROPERTIES_BASE_IRI, + CEDAR_TEMPLATE_FIELD_TYPE, + CedarTemplate, + CedarTemplateContextSchema, + CedarTemplateField, +} from '@osf/features/metadata/models'; +import { + DiscoverableFilter, + FilterOperatorOption, + FilterOption, +} from '@osf/shared/models/search/discoverable-filter.model'; export class CedarTemplateFilterMapper { static fromTemplate(template: CedarTemplate): DiscoverableFilter[] { const { order, propertyLabels } = template._ui; + const contextProperties = (template.properties['@context'] as CedarTemplateContextSchema)?.properties ?? {}; return order - .filter((key) => propertyLabels[key]?.trim()) - .map((key) => ({ - key, - label: propertyLabels[key], - operator: FilterOperatorOption.AnyOf, - })); + .filter((key) => { + const field = template.properties[key] as CedarTemplateField | undefined; + return ( + propertyLabels[key]?.trim() && + field?.['@type'] === CEDAR_TEMPLATE_FIELD_TYPE && + (field._valueConstraints?.literals?.length ?? 0) > 0 + ); + }) + .map((key) => { + const field = template.properties[key] as CedarTemplateField; + const iri = contextProperties[key]?.enum?.[0]; + const cedarPropertyIri = iri?.replace(CEDAR_PROPERTIES_BASE_IRI, ''); + const options: FilterOption[] = (field._valueConstraints!.literals ?? []).map((literal) => ({ + label: literal.label, + value: literal.label, + cardSearchResultCount: null, + })); + + return { + key, + label: propertyLabels[key], + operator: FilterOperatorOption.AnyOf, + options, + cedarPropertyIri, + }; + }); } } diff --git a/src/app/shared/models/search/discoverable-filter.model.ts b/src/app/shared/models/search/discoverable-filter.model.ts index fb313ee49..005f9b1dc 100644 --- a/src/app/shared/models/search/discoverable-filter.model.ts +++ b/src/app/shared/models/search/discoverable-filter.model.ts @@ -11,6 +11,7 @@ export interface DiscoverableFilter { isLoaded?: boolean; isPaginationLoading?: boolean; isSearchLoading?: boolean; + cedarPropertyIri?: string; } export enum FilterOperatorOption { @@ -22,5 +23,5 @@ export enum FilterOperatorOption { export interface FilterOption { label: string; value: string; - cardSearchResultCount: number; + cardSearchResultCount: number | null; } diff --git a/src/app/shared/stores/global-search/global-search.state.spec.ts b/src/app/shared/stores/global-search/global-search.state.spec.ts new file mode 100644 index 000000000..62ff9403b --- /dev/null +++ b/src/app/shared/stores/global-search/global-search.state.spec.ts @@ -0,0 +1,203 @@ +import { provideStore, Store } from '@ngxs/store'; + +import { EMPTY, of } from 'rxjs'; + +import { vi } from 'vitest'; + +import { TestBed } from '@angular/core/testing'; + +import { + DiscoverableFilter, + FilterOperatorOption, + FilterOption, +} from '@osf/shared/models/search/discoverable-filter.model'; +import { GlobalSearchService } from '@osf/shared/services/global-search.service'; +import { ResourcesData } from '@shared/models/search/resource.model'; + +import { provideOSFCore } from '@testing/osf.testing.provider'; + +import { + FetchResources, + LoadFilterOptions, + LoadFilterOptionsAndSetValues, + SetExtraFilters, +} from './global-search.actions'; +import { GlobalSearchSelectors } from './global-search.selectors'; +import { GlobalSearchState } from './global-search.state'; + +const MOCK_RESOURCES_DATA: ResourcesData = { + resources: [], + filters: [], + count: 0, + self: '', + first: null, + next: null, + previous: null, +}; + +const CEDAR_FILTER: DiscoverableFilter = { + key: 'School Type', + label: 'School Type', + operator: FilterOperatorOption.AnyOf, + cedarPropertyIri: 'uuid-school-type', + options: [ + { label: 'High School', value: 'High School', cardSearchResultCount: null }, + { label: 'Middle School', value: 'Middle School', cardSearchResultCount: null }, + ], +}; + +const REGULAR_FILTER: DiscoverableFilter = { + key: 'subject', + label: 'Subject', + operator: FilterOperatorOption.AnyOf, + resultCount: 10, +}; + +function setup() { + const mockGetResources = vi.fn().mockReturnValue(of(MOCK_RESOURCES_DATA)); + const mockGetFilterOptions = vi.fn().mockReturnValue(of({ options: [], nextUrl: undefined })); + + TestBed.configureTestingModule({ + providers: [ + provideOSFCore(), + provideStore([GlobalSearchState]), + { + provide: GlobalSearchService, + useValue: { + getResources: mockGetResources, + getFilterOptions: mockGetFilterOptions, + getResourcesByLink: vi.fn().mockReturnValue(EMPTY), + getFilterOptionsFromPaginationUrl: vi.fn().mockReturnValue(EMPTY), + }, + }, + ], + }); + + return { + store: TestBed.inject(Store), + mockGetResources, + mockGetFilterOptions, + }; +} + +describe('GlobalSearchState', () => { + describe('LoadFilterOptions', () => { + it('should skip the API call for a CEDAR filter (cedarPropertyIri set)', () => { + const { store, mockGetFilterOptions } = setup(); + + store.dispatch(new SetExtraFilters([CEDAR_FILTER])); + store.dispatch(new FetchResources()); + store.dispatch(new LoadFilterOptions(CEDAR_FILTER.key)); + + expect(mockGetFilterOptions).not.toHaveBeenCalled(); + }); + + it('should call the API for a regular filter', () => { + const { store, mockGetFilterOptions } = setup(); + + store.dispatch(new FetchResources()); + store.dispatch(new LoadFilterOptions(REGULAR_FILTER.key)); + + expect(mockGetFilterOptions).toHaveBeenCalled(); + const params = mockGetFilterOptions.mock.calls[0][0]; + expect(params['valueSearchPropertyPath']).toBe(REGULAR_FILTER.key); + }); + }); + + describe('LoadFilterOptionsAndSetValues', () => { + it('should not call the API for CEDAR filter keys', () => { + const { store, mockGetFilterOptions } = setup(); + store.dispatch(new SetExtraFilters([CEDAR_FILTER])); + + store.dispatch( + new LoadFilterOptionsAndSetValues({ + [CEDAR_FILTER.key]: [{ label: 'High School', value: 'High School', cardSearchResultCount: null }], + }) + ); + + expect(mockGetFilterOptions).not.toHaveBeenCalled(); + }); + + it('should still set selectedFilterOptions for CEDAR keys', () => { + const { store } = setup(); + store.dispatch(new SetExtraFilters([CEDAR_FILTER])); + + const selectedOption: FilterOption = { label: 'High School', value: 'High School', cardSearchResultCount: null }; + store.dispatch(new LoadFilterOptionsAndSetValues({ [CEDAR_FILTER.key]: [selectedOption] })); + + const selected = store.selectSnapshot(GlobalSearchSelectors.getSelectedOptions); + expect(selected[CEDAR_FILTER.key]).toEqual([selectedOption]); + }); + + it('should only call the API for non-CEDAR keys in a mixed payload', () => { + const { store, mockGetFilterOptions } = setup(); + store.dispatch(new SetExtraFilters([CEDAR_FILTER])); + + store.dispatch( + new LoadFilterOptionsAndSetValues({ + [CEDAR_FILTER.key]: [{ label: 'High School', value: 'High School', cardSearchResultCount: null }], + [REGULAR_FILTER.key]: [{ label: 'Biology', value: 'biology', cardSearchResultCount: 5 }], + }) + ); + + expect(mockGetFilterOptions).toHaveBeenCalledTimes(1); + const params = mockGetFilterOptions.mock.calls[0][0]; + expect(params['valueSearchPropertyPath']).toBe(REGULAR_FILTER.key); + }); + }); + + describe('FetchResources (CEDAR filter params)', () => { + it('should add iriShorthand[cedar] when extraFilters are present', () => { + const { store, mockGetResources } = setup(); + store.dispatch(new SetExtraFilters([CEDAR_FILTER])); + + store.dispatch(new FetchResources()); + + const params = mockGetResources.mock.calls[0][0]; + expect(params['iriShorthand[cedar]']).toBe('https://schema.metadatacenter.org/properties/'); + }); + + it('should not add iriShorthand[cedar] when no extraFilters are present', () => { + const { store, mockGetResources } = setup(); + + store.dispatch(new FetchResources()); + + const params = mockGetResources.mock.calls[0][0]; + expect(params['iriShorthand[cedar]']).toBeUndefined(); + }); + + it('should use cardSearchText for a selected CEDAR filter value', () => { + const { store, mockGetResources } = setup(); + store.dispatch(new SetExtraFilters([CEDAR_FILTER])); + store.dispatch(new FetchResources()); // populates state.filters via updateResourcesState + mockGetResources.mockClear(); + + store.dispatch( + new LoadFilterOptionsAndSetValues({ + [CEDAR_FILTER.key]: [{ label: 'High School', value: 'High School', cardSearchResultCount: null }], + }) + ); + store.dispatch(new FetchResources()); + + const params = mockGetResources.mock.calls[0][0]; + expect(params[`cardSearchText[osf:hasCedarRecord.cedar:${CEDAR_FILTER.cedarPropertyIri}]`]).toBe('"High School"'); + expect(params[`cardSearchFilter[${CEDAR_FILTER.key}][]`]).toBeUndefined(); + }); + + it('should use extraFilters as fallback for CEDAR lookup before state.filters is populated', () => { + const { store, mockGetResources } = setup(); + store.dispatch(new SetExtraFilters([CEDAR_FILTER])); + + store.dispatch( + new LoadFilterOptionsAndSetValues({ + [CEDAR_FILTER.key]: [{ label: 'High School', value: 'High School', cardSearchResultCount: null }], + }) + ); + // First FetchResources — state.filters is still empty at this point + store.dispatch(new FetchResources()); + + const params = mockGetResources.mock.calls[0][0]; + expect(params[`cardSearchText[osf:hasCedarRecord.cedar:${CEDAR_FILTER.cedarPropertyIri}]`]).toBe('"High School"'); + }); + }); +}); diff --git a/src/app/shared/stores/global-search/global-search.state.ts b/src/app/shared/stores/global-search/global-search.state.ts index b20d061b4..205e8ad01 100644 --- a/src/app/shared/stores/global-search/global-search.state.ts +++ b/src/app/shared/stores/global-search/global-search.state.ts @@ -62,6 +62,12 @@ export class GlobalSearchState { loadFilterOptions(ctx: StateContext, action: LoadFilterOptions) { const state = ctx.getState(); const filterKey = action.filterKey; + + const filter = state.filters.find((f) => f.key === filterKey); + if (filter?.cedarPropertyIri) { + return EMPTY; + } + const cachedOptions = state.filterOptionsCache[filterKey]; if (cachedOptions?.length) { const updatedFilters = state.filters.map((f) => @@ -204,7 +210,12 @@ export class GlobalSearchState { ctx.patchState({ filters: loadingFilters }); ctx.patchState({ selectedFilterOptions: filterValues }); - const observables = filterKeys.map((key) => + const cedarKeys = new Set(ctx.getState().extraFilters.map((f) => f.key)); + const nonCedarKeys = filterKeys.filter((key) => !cedarKeys.has(key)); + + if (!nonCedarKeys.length) return; + + const observables = nonCedarKeys.map((key) => this.searchService.getFilterOptions(this.buildParamsForIndexValueSearch(ctx.getState(), key)).pipe( tap((response) => { const options = response.options; @@ -310,20 +321,34 @@ export class GlobalSearchState { Object.entries(state.defaultFilterOptions).forEach(([key, value]) => { filtersParams[`cardSearchFilter[${key}][]`] = value; }); + let hasCedarFilters = state.extraFilters.length > 0; + Object.entries(state.selectedFilterOptions).forEach(([key, options]) => { - const filter = state.filters.find((f) => f.key === key); + const filter = state.filters.find((f) => f.key === key) ?? state.extraFilters.find((f) => f.key === key); - const firstOptionValue = options[0]?.value; - const isOptionValueBoolean = firstOptionValue === 'true' || firstOptionValue === 'false'; - if (filter?.operator === FilterOperatorOption.IsPresent || isOptionValueBoolean) { - if (firstOptionValue) { - filtersParams[`cardSearchFilter[${key}][is-present]`] = firstOptionValue; + if (filter?.cedarPropertyIri) { + hasCedarFilters = true; + const value = options[0]?.value; + if (value) { + filtersParams[`cardSearchText[osf:hasCedarRecord.cedar:${filter.cedarPropertyIri}]`] = `"${value}"`; } } else { - filtersParams[`cardSearchFilter[${key}][]`] = options.map((option) => option.value); + const firstOptionValue = options[0]?.value; + const isOptionValueBoolean = firstOptionValue === 'true' || firstOptionValue === 'false'; + if (filter?.operator === FilterOperatorOption.IsPresent || isOptionValueBoolean) { + if (firstOptionValue) { + filtersParams[`cardSearchFilter[${key}][is-present]`] = firstOptionValue; + } + } else { + filtersParams[`cardSearchFilter[${key}][]`] = options.map((option) => option.value); + } } }); + if (hasCedarFilters) { + filtersParams['iriShorthand[cedar]'] = 'https://schema.metadatacenter.org/properties/'; + } + filtersParams['cardSearchFilter[resourceType]'] = getResourceTypeStringFromEnum(state.resourceType); filtersParams['cardSearchFilter[accessService]'] = `${this.environment.webUrl}/`; filtersParams['cardSearchText[*,creator.name,isContainedBy.creator.name]'] = state.searchText From fc98d724805ea71e0e6273ea99af700bc3b10bf3 Mon Sep 17 00:00:00 2001 From: Yuhuai Liu Date: Fri, 5 Jun 2026 00:27:37 -0400 Subject: [PATCH 20/32] feat(es2): CR followup --- .../global-search/global-search.state.spec.ts | 43 ++++++++++++++++++- .../global-search/global-search.state.ts | 9 ++-- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/app/shared/stores/global-search/global-search.state.spec.ts b/src/app/shared/stores/global-search/global-search.state.spec.ts index 62ff9403b..b43908615 100644 --- a/src/app/shared/stores/global-search/global-search.state.spec.ts +++ b/src/app/shared/stores/global-search/global-search.state.spec.ts @@ -92,6 +92,18 @@ describe('GlobalSearchState', () => { expect(mockGetFilterOptions).not.toHaveBeenCalled(); }); + it('should set isLoaded to true for a CEDAR filter when short-circuiting', () => { + const { store } = setup(); + + store.dispatch(new SetExtraFilters([CEDAR_FILTER])); + store.dispatch(new FetchResources()); + store.dispatch(new LoadFilterOptions(CEDAR_FILTER.key)); + + const filters = store.selectSnapshot(GlobalSearchSelectors.getFilters); + const cedarFilterState = filters.find((f) => f.key === CEDAR_FILTER.key); + expect(cedarFilterState?.isLoaded).toBe(true); + }); + it('should call the API for a regular filter', () => { const { store, mockGetFilterOptions } = setup(); @@ -180,10 +192,35 @@ describe('GlobalSearchState', () => { store.dispatch(new FetchResources()); const params = mockGetResources.mock.calls[0][0]; - expect(params[`cardSearchText[osf:hasCedarRecord.cedar:${CEDAR_FILTER.cedarPropertyIri}]`]).toBe('"High School"'); + expect(params[`cardSearchText[osf:hasCedarRecord.cedar:${CEDAR_FILTER.cedarPropertyIri}][]`]).toEqual([ + '"High School"', + ]); expect(params[`cardSearchFilter[${CEDAR_FILTER.key}][]`]).toBeUndefined(); }); + it('should include all selected values for a CEDAR filter', () => { + const { store, mockGetResources } = setup(); + store.dispatch(new SetExtraFilters([CEDAR_FILTER])); + store.dispatch(new FetchResources()); + mockGetResources.mockClear(); + + store.dispatch( + new LoadFilterOptionsAndSetValues({ + [CEDAR_FILTER.key]: [ + { label: 'High School', value: 'High School', cardSearchResultCount: null }, + { label: 'Middle School', value: 'Middle School', cardSearchResultCount: null }, + ], + }) + ); + store.dispatch(new FetchResources()); + + const params = mockGetResources.mock.calls[0][0]; + expect(params[`cardSearchText[osf:hasCedarRecord.cedar:${CEDAR_FILTER.cedarPropertyIri}][]`]).toEqual([ + '"High School"', + '"Middle School"', + ]); + }); + it('should use extraFilters as fallback for CEDAR lookup before state.filters is populated', () => { const { store, mockGetResources } = setup(); store.dispatch(new SetExtraFilters([CEDAR_FILTER])); @@ -197,7 +234,9 @@ describe('GlobalSearchState', () => { store.dispatch(new FetchResources()); const params = mockGetResources.mock.calls[0][0]; - expect(params[`cardSearchText[osf:hasCedarRecord.cedar:${CEDAR_FILTER.cedarPropertyIri}]`]).toBe('"High School"'); + expect(params[`cardSearchText[osf:hasCedarRecord.cedar:${CEDAR_FILTER.cedarPropertyIri}][]`]).toEqual([ + '"High School"', + ]); }); }); }); 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 205e8ad01..5e80a3541 100644 --- a/src/app/shared/stores/global-search/global-search.state.ts +++ b/src/app/shared/stores/global-search/global-search.state.ts @@ -65,6 +65,9 @@ export class GlobalSearchState { const filter = state.filters.find((f) => f.key === filterKey); if (filter?.cedarPropertyIri) { + ctx.patchState({ + filters: state.filters.map((f) => (f.key === filterKey ? { ...f, isLoaded: true } : f)), + }); return EMPTY; } @@ -328,9 +331,9 @@ export class GlobalSearchState { if (filter?.cedarPropertyIri) { hasCedarFilters = true; - const value = options[0]?.value; - if (value) { - filtersParams[`cardSearchText[osf:hasCedarRecord.cedar:${filter.cedarPropertyIri}]`] = `"${value}"`; + const values = options.map((o) => `"${o.value}"`); + if (values.length) { + filtersParams[`cardSearchText[osf:hasCedarRecord.cedar:${filter.cedarPropertyIri}][]`] = values; } } else { const firstOptionValue = options[0]?.value; From c38c894872ffef49ccbdd0f48787a4b99e918b79 Mon Sep 17 00:00:00 2001 From: Yuhuai Liu Date: Mon, 8 Jun 2026 11:49:31 -0400 Subject: [PATCH 21/32] feat(es2): Only show non-cedar templates when adding metadata --- .../metadata/models/cedar-metadata-template.model.ts | 1 + .../pages/add-metadata/add-metadata.component.spec.ts | 2 +- .../pages/add-metadata/add-metadata.component.ts | 4 ++-- src/app/features/metadata/store/metadata.actions.ts | 2 +- src/app/features/metadata/store/metadata.selectors.ts | 10 ++++++++++ src/app/features/metadata/store/metadata.state.ts | 2 +- 6 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/app/features/metadata/models/cedar-metadata-template.model.ts b/src/app/features/metadata/models/cedar-metadata-template.model.ts index fb2969382..453187fdf 100644 --- a/src/app/features/metadata/models/cedar-metadata-template.model.ts +++ b/src/app/features/metadata/models/cedar-metadata-template.model.ts @@ -7,6 +7,7 @@ export interface CedarMetadataDataTemplateJsonApi { schema_name: string; cedar_id: string; template: CedarTemplate; + is_for_collections: boolean; }; } diff --git a/src/app/features/metadata/pages/add-metadata/add-metadata.component.spec.ts b/src/app/features/metadata/pages/add-metadata/add-metadata.component.spec.ts index ad9b2eae5..d7cdfcf2e 100644 --- a/src/app/features/metadata/pages/add-metadata/add-metadata.component.spec.ts +++ b/src/app/features/metadata/pages/add-metadata/add-metadata.component.spec.ts @@ -78,7 +78,7 @@ describe('AddMetadataComponent', () => { MockProvider(ToastService, toastService), provideMockStore({ signals: [ - { selector: MetadataSelectors.getCedarTemplates, value: mockCedarTemplates }, + { selector: MetadataSelectors.getCedarTemplatesExcludingCollections, value: mockCedarTemplates }, { selector: MetadataSelectors.getCedarRecords, value: mockCedarRecords }, { selector: MetadataSelectors.getCedarTemplatesLoading, value: false }, { selector: MetadataSelectors.getCedarRecord, value: { data: mockRecord } }, diff --git a/src/app/features/metadata/pages/add-metadata/add-metadata.component.ts b/src/app/features/metadata/pages/add-metadata/add-metadata.component.ts index c60deb5be..8b08f254b 100644 --- a/src/app/features/metadata/pages/add-metadata/add-metadata.component.ts +++ b/src/app/features/metadata/pages/add-metadata/add-metadata.component.ts @@ -56,7 +56,7 @@ export class AddMetadataComponent implements OnInit { selectedTemplate: CedarMetadataDataTemplateJsonApi | null = null; existingRecord: CedarMetadataRecordData | null = null; - readonly cedarTemplates = select(MetadataSelectors.getCedarTemplates); + readonly cedarTemplates = select(MetadataSelectors.getCedarTemplatesExcludingCollections); readonly cedarRecords = select(MetadataSelectors.getCedarRecords); readonly cedarTemplatesLoading = select(MetadataSelectors.getCedarTemplatesLoading); readonly cedarRecord = select(MetadataSelectors.getCedarRecord); @@ -136,7 +136,7 @@ export class AddMetadataComponent implements OnInit { if (!templates?.links?.next) { return; } - this.actions.getCedarTemplates(templates.links.next); + this.actions.getCedarTemplates({ url: templates.links.next }); } hasNextPage(): boolean { diff --git a/src/app/features/metadata/store/metadata.actions.ts b/src/app/features/metadata/store/metadata.actions.ts index cc3301bca..5f5817c6b 100644 --- a/src/app/features/metadata/store/metadata.actions.ts +++ b/src/app/features/metadata/store/metadata.actions.ts @@ -65,7 +65,7 @@ export class GetFundersList { export class GetCedarMetadataTemplates { static readonly type = '[Metadata] Get Cedar Metadata Templates'; - constructor(public url?: string) {} + constructor(public options: { url?: string } = {}) {} } export class GetCedarMetadataRecords { diff --git a/src/app/features/metadata/store/metadata.selectors.ts b/src/app/features/metadata/store/metadata.selectors.ts index 7ceca5945..8289ba4df 100644 --- a/src/app/features/metadata/store/metadata.selectors.ts +++ b/src/app/features/metadata/store/metadata.selectors.ts @@ -41,6 +41,16 @@ export class MetadataSelectors { return state.cedarTemplates.data; } + @Selector([MetadataState]) + static getCedarTemplatesExcludingCollections(state: MetadataStateModel) { + const templates = state.cedarTemplates.data; + if (!templates) return null; + return { + ...templates, + data: templates.data.filter((t) => !t.attributes.is_for_collections), + }; + } + @Selector([MetadataState]) static getCedarTemplatesLoading(state: MetadataStateModel) { return state.cedarTemplates.isLoading; diff --git a/src/app/features/metadata/store/metadata.state.ts b/src/app/features/metadata/store/metadata.state.ts index 88ac11b71..86cf51901 100644 --- a/src/app/features/metadata/store/metadata.state.ts +++ b/src/app/features/metadata/store/metadata.state.ts @@ -137,7 +137,7 @@ export class MetadataState { }, }); - return this.metadataService.getMetadataCedarTemplates(action.url).pipe( + return this.metadataService.getMetadataCedarTemplates(action.options.url).pipe( tap({ next: (response) => { ctx.patchState({ From e34005e49f05a5261cf6b4b023ebbd8304779590 Mon Sep 17 00:00:00 2001 From: Yuhuai Liu Date: Mon, 8 Jun 2026 11:58:19 -0400 Subject: [PATCH 22/32] feat(es2): Fix tests --- .../features/metadata/models/cedar-metadata-template.model.ts | 1 + src/testing/data/collections/cedar-metadata.mock.ts | 1 + src/testing/mocks/cedar-metadata-data-template-json-api.mock.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/src/app/features/metadata/models/cedar-metadata-template.model.ts b/src/app/features/metadata/models/cedar-metadata-template.model.ts index 453187fdf..7b904d7ea 100644 --- a/src/app/features/metadata/models/cedar-metadata-template.model.ts +++ b/src/app/features/metadata/models/cedar-metadata-template.model.ts @@ -77,6 +77,7 @@ export interface CedarMetadataTemplate { schema_name: string; cedar_id: string; template: CedarTemplate; + is_for_collections: boolean; }; } diff --git a/src/testing/data/collections/cedar-metadata.mock.ts b/src/testing/data/collections/cedar-metadata.mock.ts index 4fbb297c3..2e8a5fc43 100644 --- a/src/testing/data/collections/cedar-metadata.mock.ts +++ b/src/testing/data/collections/cedar-metadata.mock.ts @@ -32,6 +32,7 @@ export const MOCK_CEDAR_TEMPLATE: CedarMetadataDataTemplateJsonApi = { properties: {}, _ui: { order: [], propertyLabels: {}, propertyDescriptions: {} }, }, + is_for_collections: false, }, }; diff --git a/src/testing/mocks/cedar-metadata-data-template-json-api.mock.ts b/src/testing/mocks/cedar-metadata-data-template-json-api.mock.ts index 755b01cc2..15e29a2d0 100644 --- a/src/testing/mocks/cedar-metadata-data-template-json-api.mock.ts +++ b/src/testing/mocks/cedar-metadata-data-template-json-api.mock.ts @@ -43,5 +43,6 @@ export const CEDAR_METADATA_DATA_TEMPLATE_JSON_API_MOCK: CedarMetadataTemplate = }, }, }, + is_for_collections: false, }, }; From 42df5f59ce639e483684a159866d970ab5f61048 Mon Sep 17 00:00:00 2001 From: Yuhuai Liu Date: Tue, 9 Jun 2026 13:29:27 -0400 Subject: [PATCH 23/32] feat(es2): revert query param --- .../metadata/pages/add-metadata/add-metadata.component.ts | 2 +- src/app/features/metadata/store/metadata.actions.ts | 2 +- src/app/features/metadata/store/metadata.state.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/features/metadata/pages/add-metadata/add-metadata.component.ts b/src/app/features/metadata/pages/add-metadata/add-metadata.component.ts index 8b08f254b..20b1d67ef 100644 --- a/src/app/features/metadata/pages/add-metadata/add-metadata.component.ts +++ b/src/app/features/metadata/pages/add-metadata/add-metadata.component.ts @@ -136,7 +136,7 @@ export class AddMetadataComponent implements OnInit { if (!templates?.links?.next) { return; } - this.actions.getCedarTemplates({ url: templates.links.next }); + this.actions.getCedarTemplates(templates.links.next); } hasNextPage(): boolean { diff --git a/src/app/features/metadata/store/metadata.actions.ts b/src/app/features/metadata/store/metadata.actions.ts index 5f5817c6b..cc3301bca 100644 --- a/src/app/features/metadata/store/metadata.actions.ts +++ b/src/app/features/metadata/store/metadata.actions.ts @@ -65,7 +65,7 @@ export class GetFundersList { export class GetCedarMetadataTemplates { static readonly type = '[Metadata] Get Cedar Metadata Templates'; - constructor(public options: { url?: string } = {}) {} + constructor(public url?: string) {} } export class GetCedarMetadataRecords { diff --git a/src/app/features/metadata/store/metadata.state.ts b/src/app/features/metadata/store/metadata.state.ts index 86cf51901..88ac11b71 100644 --- a/src/app/features/metadata/store/metadata.state.ts +++ b/src/app/features/metadata/store/metadata.state.ts @@ -137,7 +137,7 @@ export class MetadataState { }, }); - return this.metadataService.getMetadataCedarTemplates(action.options.url).pipe( + return this.metadataService.getMetadataCedarTemplates(action.url).pipe( tap({ next: (response) => { ctx.patchState({ From bed1c9167f5b67f006dfa459aa67ab703861c65e Mon Sep 17 00:00:00 2001 From: Yuhuai Liu Date: Tue, 9 Jun 2026 13:58:40 -0400 Subject: [PATCH 24/32] feat(es2): Add filter and tests --- .../select-project-step.component.html | 1 + .../project-selector.component.spec.ts | 40 ++++++++++++++++++- .../project-selector.component.ts | 9 ++++- 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.html b/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.html index a17d3deab..ceae12d81 100644 --- a/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.html +++ b/src/app/features/collections/components/add-to-collection/select-project-step/select-project-step.component.html @@ -26,6 +26,7 @@

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

+ ({ id, title: `Project ${id}`, isPublic }) as ProjectModel; + describe('ProjectSelectorComponent', () => { let component: ProjectSelectorComponent; let fixture: ComponentFixture; + let store: Store; beforeEach(() => { TestBed.configureTestingModule({ @@ -22,6 +27,7 @@ describe('ProjectSelectorComponent', () => { providers: [provideOSFCore(), MockProvider(ToastService), provideStore([ProjectsState, UserState])], }); + store = TestBed.inject(Store); fixture = TestBed.createComponent(ProjectSelectorComponent); component = fixture.componentInstance; fixture.detectChanges(); @@ -51,4 +57,36 @@ describe('ProjectSelectorComponent', () => { expect(mockEvent.originalEvent.preventDefault).toHaveBeenCalled(); }); + + describe('publicOnly filtering', () => { + const publicProject = makeProject('1', true); + const privateProject = makeProject('2', false); + + const setProjects = (projects: ProjectModel[]) => { + store.reset({ + ...store.snapshot(), + projects: { projects: { data: projects, isLoading: false, error: null } }, + }); + }; + + it('should show all projects when publicOnly is false', () => { + fixture.componentRef.setInput('publicOnly', false); + setProjects([publicProject, privateProject]); + fixture.detectChanges(); + + const ids = component.projectsOptions().map((o) => o.value.id); + expect(ids).toContain('1'); + expect(ids).toContain('2'); + }); + + it('should only show public projects when publicOnly is true', () => { + fixture.componentRef.setInput('publicOnly', true); + setProjects([publicProject, privateProject]); + fixture.detectChanges(); + + const ids = component.projectsOptions().map((o) => o.value.id); + expect(ids).toContain('1'); + expect(ids).not.toContain('2'); + }); + }); }); diff --git a/src/app/shared/components/project-selector/project-selector.component.ts b/src/app/shared/components/project-selector/project-selector.component.ts index 5cd711e0f..2b7597ec8 100644 --- a/src/app/shared/components/project-selector/project-selector.component.ts +++ b/src/app/shared/components/project-selector/project-selector.component.ts @@ -44,6 +44,7 @@ export class ProjectSelectorComponent { placeholder = input('common.buttons.select'); showClear = input(true); excludeProjectIds = input([]); + publicOnly = input(false); selectedProject = model(null); projectChange = output(); @@ -105,7 +106,9 @@ export class ProjectSelectorComponent { } const excludeSet = new Set(excludeIds); - const availableProjects = projects.filter((project) => !excludeSet.has(project.id)); + const availableProjects = projects.filter( + (project) => !excludeSet.has(project.id) && (!this.publicOnly() || project.isPublic) + ); const options = availableProjects.map((project) => ({ label: project.title, @@ -132,6 +135,10 @@ export class ProjectSelectorComponent { 'filter[current_user_permissions]': 'admin', }; + if (this.publicOnly()) { + params['filter[public]'] = 'true'; + } + if (filterTitle && filterTitle.trim()) { params['filter[title]'] = filterTitle; } From cff3b4ea3a16bd9b46f11715b498254e08aba415 Mon Sep 17 00:00:00 2001 From: Vlad0n20 Date: Fri, 12 Jun 2026 14:43:05 +0200 Subject: [PATCH 25/32] feat(ENG-11333): fix selected filters from overriding default filters --- .../global-search/global-search.state.spec.ts | 25 +++++++++++++++++++ .../global-search/global-search.state.ts | 1 + 2 files changed, 26 insertions(+) diff --git a/src/app/shared/stores/global-search/global-search.state.spec.ts b/src/app/shared/stores/global-search/global-search.state.spec.ts index b43908615..dd78a40d0 100644 --- a/src/app/shared/stores/global-search/global-search.state.spec.ts +++ b/src/app/shared/stores/global-search/global-search.state.spec.ts @@ -20,7 +20,9 @@ import { FetchResources, LoadFilterOptions, LoadFilterOptionsAndSetValues, + SetDefaultFilterValue, SetExtraFilters, + UpdateSelectedFilterOption, } from './global-search.actions'; import { GlobalSearchSelectors } from './global-search.selectors'; import { GlobalSearchState } from './global-search.state'; @@ -239,4 +241,27 @@ describe('GlobalSearchState', () => { ]); }); }); + + describe('SetDefaultFilterValue', () => { + it('should include the default filter in the API call', () => { + const { store, mockGetResources } = setup(); + + store.dispatch(new SetDefaultFilterValue('defaultKey', 'default-value')); + store.dispatch(new FetchResources()); + + const params = mockGetResources.mock.calls[0][0]; + expect(params['cardSearchFilter[defaultKey][]']).toBe('default-value'); + }); + + it('should not be overridden when a selected filter for the same key is cleared', () => { + const { store, mockGetResources } = setup(); + + store.dispatch(new SetDefaultFilterValue('defaultKey', 'default-value')); + store.dispatch(new UpdateSelectedFilterOption('defaultKey', [])); + store.dispatch(new FetchResources()); + + const params = mockGetResources.mock.calls[0][0]; + expect(params['cardSearchFilter[defaultKey][]']).toBe('default-value'); + }); + }); }); 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 5e80a3541..bc7bb2710 100644 --- a/src/app/shared/stores/global-search/global-search.state.ts +++ b/src/app/shared/stores/global-search/global-search.state.ts @@ -327,6 +327,7 @@ export class GlobalSearchState { let hasCedarFilters = state.extraFilters.length > 0; Object.entries(state.selectedFilterOptions).forEach(([key, options]) => { + if (key in state.defaultFilterOptions) return; const filter = state.filters.find((f) => f.key === key) ?? state.extraFilters.find((f) => f.key === key); if (filter?.cedarPropertyIri) { From dc90c66ff2fa4dfcfbbb4ef4ffcf6928e2850973 Mon Sep 17 00:00:00 2001 From: Vlad0n20 Date: Fri, 12 Jun 2026 14:53:55 +0200 Subject: [PATCH 26/32] fix(ENG-11358): show cedar metadata in collection accordion on project overview --- .../overview-collections.component.html | 30 ++++++-- .../overview-collections.component.spec.ts | 73 +++++++++++++++++++ .../overview-collections.component.ts | 33 ++++++++- .../project-overview-metadata.component.html | 3 + ...roject-overview-metadata.component.spec.ts | 7 ++ .../project-overview-metadata.component.ts | 14 +++- 6 files changed, 150 insertions(+), 10 deletions(-) diff --git a/src/app/features/project/overview/components/overview-collections/overview-collections.component.html b/src/app/features/project/overview/components/overview-collections/overview-collections.component.html index 5a18e2d72..2b7e65d86 100644 --- a/src/app/features/project/overview/components/overview-collections/overview-collections.component.html +++ b/src/app/features/project/overview/components/overview-collections/overview-collections.component.html @@ -29,16 +29,30 @@

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

- @let attributes = getSubmissionAttributes(submission); + @let templateId = submission.requiredMetadataTemplateId ?? ''; + @let cedarRecord = cedarRecordByTemplateId().get(templateId) ?? null; + @let cedarTemplate = cedarTemplateById().get(templateId) ?? null; - @if (attributes.length) { -
- @for (attribute of attributes; track attribute.key) { -

- {{ attribute.label }}: {{ attribute.value }} -

- } + @if (isCedarMode() && cedarRecord && cedarTemplate?.attributes?.template) { +
+
+ } @else { + @let attributes = getSubmissionAttributes(submission); + + @if (attributes.length) { +
+ @for (attribute of attributes; track attribute.key) { +

+ {{ attribute.label }}: {{ attribute.value }} +

+ } +
+ } } diff --git a/src/app/features/project/overview/components/overview-collections/overview-collections.component.spec.ts b/src/app/features/project/overview/components/overview-collections/overview-collections.component.spec.ts index 020aee7b6..c279a7aef 100644 --- a/src/app/features/project/overview/components/overview-collections/overview-collections.component.spec.ts +++ b/src/app/features/project/overview/components/overview-collections/overview-collections.component.spec.ts @@ -2,8 +2,11 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; import { collectionFilterNames } from '@osf/features/collections/constants'; +import { CedarMetadataDataTemplateJsonApi, CedarMetadataRecordData } from '@osf/features/metadata/models'; import { CollectionSubmission } from '@osf/shared/models/collections/collections.model'; +import { CEDAR_METADATA_DATA_TEMPLATE_JSON_API_MOCK } from '@testing/mocks/cedar-metadata-data-template-json-api.mock'; +import { MOCK_CEDAR_METADATA_RECORD_DATA } from '@testing/mocks/cedar-metadata-record.mock'; import { MOCK_COLLECTION_SUBMISSION_EMPTY_FILTERS, MOCK_COLLECTION_SUBMISSION_SINGLE_FILTER, @@ -92,4 +95,74 @@ describe('OverviewCollectionsComponent', () => { expect(statusAttr?.value).toBe('1'); expect(typeof statusAttr?.value).toBe('string'); }); + + it('should render cedar-artifact-viewer when isCedarMode is true with matching record and template', async () => { + const cedarSubmission: CollectionSubmission = { + ...MOCK_COLLECTION_SUBMISSION_EMPTY_FILTERS, + requiredMetadataTemplateId: 'template-1', + }; + const cedarRecord: CedarMetadataRecordData = MOCK_CEDAR_METADATA_RECORD_DATA; + const cedarTemplate: CedarMetadataDataTemplateJsonApi = + CEDAR_METADATA_DATA_TEMPLATE_JSON_API_MOCK as CedarMetadataDataTemplateJsonApi; + + fixture.componentRef.setInput('projectSubmissions', [cedarSubmission]); + fixture.componentRef.setInput('isCedarMode', true); + fixture.componentRef.setInput('cedarRecords', [cedarRecord]); + fixture.componentRef.setInput('cedarTemplates', [cedarTemplate]); + fixture.detectChanges(); + await fixture.whenStable(); + + const viewer = fixture.nativeElement.querySelector('cedar-artifact-viewer'); + expect(viewer).toBeTruthy(); + }); + + it('should not render cedar-artifact-viewer when isCedarMode is false', async () => { + const cedarSubmission: CollectionSubmission = { + ...MOCK_COLLECTION_SUBMISSION_WITH_FILTERS, + requiredMetadataTemplateId: 'template-1', + }; + const cedarRecord: CedarMetadataRecordData = MOCK_CEDAR_METADATA_RECORD_DATA; + const cedarTemplate: CedarMetadataDataTemplateJsonApi = + CEDAR_METADATA_DATA_TEMPLATE_JSON_API_MOCK as CedarMetadataDataTemplateJsonApi; + + fixture.componentRef.setInput('projectSubmissions', [cedarSubmission]); + fixture.componentRef.setInput('isCedarMode', false); + fixture.componentRef.setInput('cedarRecords', [cedarRecord]); + fixture.componentRef.setInput('cedarTemplates', [cedarTemplate]); + fixture.detectChanges(); + await fixture.whenStable(); + + const viewer = fixture.nativeElement.querySelector('cedar-artifact-viewer'); + expect(viewer).toBeNull(); + }); + + it('should show traditional attributes when isCedarMode is true but no matching record', async () => { + const cedarSubmission: CollectionSubmission = { + ...MOCK_COLLECTION_SUBMISSION_WITH_FILTERS, + requiredMetadataTemplateId: 'non-existent-template', + }; + + fixture.componentRef.setInput('projectSubmissions', [cedarSubmission]); + fixture.componentRef.setInput('isCedarMode', true); + fixture.componentRef.setInput('cedarRecords', []); + fixture.componentRef.setInput('cedarTemplates', []); + fixture.detectChanges(); + await fixture.whenStable(); + + const viewer = fixture.nativeElement.querySelector('cedar-artifact-viewer'); + expect(viewer).toBeNull(); + expect(component.getSubmissionAttributes(cedarSubmission).length).toBeGreaterThan(0); + }); + + it('should compute empty cedarRecordByTemplateId map when cedarRecords is null', () => { + fixture.componentRef.setInput('cedarRecords', null); + fixture.detectChanges(); + expect(component.cedarRecordByTemplateId().size).toBe(0); + }); + + it('should compute empty cedarTemplateById map when cedarTemplates is null', () => { + fixture.componentRef.setInput('cedarTemplates', null); + fixture.detectChanges(); + expect(component.cedarTemplateById().size).toBe(0); + }); }); diff --git a/src/app/features/project/overview/components/overview-collections/overview-collections.component.ts b/src/app/features/project/overview/components/overview-collections/overview-collections.component.ts index 168a3530b..a9c51aa97 100644 --- a/src/app/features/project/overview/components/overview-collections/overview-collections.component.ts +++ b/src/app/features/project/overview/components/overview-collections/overview-collections.component.ts @@ -5,10 +5,19 @@ import { Button } from 'primeng/button'; import { Skeleton } from 'primeng/skeleton'; import { Tag } from 'primeng/tag'; -import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + computed, + CUSTOM_ELEMENTS_SCHEMA, + input, + ViewEncapsulation, +} from '@angular/core'; import { RouterLink } from '@angular/router'; import { collectionFilterNames } from '@osf/features/collections/constants'; +import { CEDAR_VIEWER_CONFIG } from '@osf/features/metadata/constants'; +import { CedarMetadataDataTemplateJsonApi, CedarMetadataRecordData } from '@osf/features/metadata/models'; import { StopPropagationDirective } from '@osf/shared/directives/stop-propagation.directive'; import { CollectionSubmission } from '@osf/shared/models/collections/collections.model'; import { KeyValueModel } from '@osf/shared/models/common/key-value.model'; @@ -32,10 +41,32 @@ import { CollectionStatusSeverityPipe } from '@osf/shared/pipes/collection-statu templateUrl: './overview-collections.component.html', styleUrl: './overview-collections.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + encapsulation: ViewEncapsulation.None, }) export class OverviewCollectionsComponent { projectSubmissions = input(null); isProjectSubmissionsLoading = input(false); + isCedarMode = input(false); + cedarRecords = input(null); + cedarTemplates = input(null); + + cedarViewerConfig = CEDAR_VIEWER_CONFIG; + + cedarRecordByTemplateId = computed(() => { + const records = this.cedarRecords(); + return new Map( + records?.flatMap((record) => { + const templateId = record.relationships?.template?.data?.id; + return templateId ? [[templateId, record] as const] : []; + }) ?? [] + ); + }); + + cedarTemplateById = computed(() => { + const templates = this.cedarTemplates(); + return new Map(templates?.map((t) => [t.id, t] as const) ?? []); + }); getSubmissionAttributes(submission: CollectionSubmission): KeyValueModel[] { const attributes: KeyValueModel[] = []; diff --git a/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.html b/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.html index 8c9b34ff6..e918fdafe 100644 --- a/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.html +++ b/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.html @@ -83,6 +83,9 @@

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

diff --git a/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.spec.ts b/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.spec.ts index f896c2108..13202745e 100644 --- a/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.spec.ts +++ b/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.spec.ts @@ -7,6 +7,8 @@ import { Mock } from 'vitest'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; +import { UserSelectors } from '@core/store/user'; +import { GetCedarMetadataRecords, GetCedarMetadataTemplates, MetadataSelectors } from '@osf/features/metadata/store'; import { AffiliatedInstitutionsViewComponent } from '@osf/shared/components/affiliated-institutions-view/affiliated-institutions-view.component'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; import { ResourceCitationsComponent } from '@osf/shared/components/resource-citations/resource-citations.component'; @@ -94,6 +96,9 @@ describe('ProjectOverviewMetadataComponent', () => { { selector: ContributorsSelectors.hasMoreBibliographicContributors, value: false }, { selector: CollectionsSelectors.getCurrentProjectSubmissions, value: [] }, { selector: CollectionsSelectors.getCurrentProjectSubmissionsLoading, value: false }, + { selector: UserSelectors.getActiveFlags, value: [] }, + { selector: MetadataSelectors.getCedarRecords, value: [] }, + { selector: MetadataSelectors.getCedarTemplates, value: null }, ], }), ], @@ -122,6 +127,8 @@ describe('ProjectOverviewMetadataComponent', () => { expect(dispatchMock).toHaveBeenCalledWith(new FetchSelectedSubjects('project-1', ResourceType.Project)); expect(dispatchMock).toHaveBeenCalledWith(new GetProjectSubmissions('project-1')); expect(dispatchMock).toHaveBeenCalledWith(new GetProjectLicense(MOCK_PROJECT_OVERVIEW.licenseId)); + expect(dispatchMock).toHaveBeenCalledWith(new GetCedarMetadataRecords('project-1', ResourceType.Project)); + expect(dispatchMock).toHaveBeenCalledWith(new GetCedarMetadataTemplates()); }); it('should not dispatch init actions when project is null', () => { diff --git a/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.ts b/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.ts index 40e0507ae..55b3e571a 100644 --- a/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.ts +++ b/src/app/features/project/overview/components/project-overview-metadata/project-overview-metadata.component.ts @@ -5,9 +5,11 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { DatePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, effect, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, effect, inject } from '@angular/core'; import { Router, RouterLink } from '@angular/router'; +import { UserSelectors } from '@core/store/user'; +import { GetCedarMetadataRecords, GetCedarMetadataTemplates, MetadataSelectors } from '@osf/features/metadata/store'; import { AffiliatedInstitutionsViewComponent } from '@osf/shared/components/affiliated-institutions-view/affiliated-institutions-view.component'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; import { ResourceCitationsComponent } from '@osf/shared/components/resource-citations/resource-citations.component'; @@ -24,6 +26,7 @@ import { LoadMoreBibliographicContributors, } from '@osf/shared/stores/contributors'; import { FetchSelectedSubjects, SubjectsSelectors } from '@osf/shared/stores/subjects'; +import { COLLECTION_SUBMISSION_WITH_CEDAR } from '@shared/constants/feature-flags.const'; import { GetProjectIdentifiers, @@ -79,6 +82,11 @@ export class ProjectOverviewMetadataComponent { readonly hasMoreBibliographicContributors = select(ContributorsSelectors.hasMoreBibliographicContributors); readonly projectSubmissions = select(CollectionsSelectors.getCurrentProjectSubmissions); readonly isProjectSubmissionsLoading = select(CollectionsSelectors.getCurrentProjectSubmissionsLoading); + readonly activeFlags = select(UserSelectors.getActiveFlags); + readonly cedarRecords = select(MetadataSelectors.getCedarRecords); + private readonly cedarTemplatesResponse = select(MetadataSelectors.getCedarTemplates); + readonly cedarTemplates = computed(() => this.cedarTemplatesResponse()?.data ?? null); + readonly isCedarMode = computed(() => this.activeFlags().includes(COLLECTION_SUBMISSION_WITH_CEDAR)); readonly resourceType = CurrentResourceType.Projects; readonly dateFormat = 'MMM d, y, h:mm a'; @@ -93,6 +101,8 @@ export class ProjectOverviewMetadataComponent { getProjectSubmissions: GetProjectSubmissions, getBibliographicContributors: GetBibliographicContributors, loadMoreBibliographicContributors: LoadMoreBibliographicContributors, + getCedarRecords: GetCedarMetadataRecords, + getCedarTemplates: GetCedarMetadataTemplates, }); constructor() { @@ -107,6 +117,8 @@ export class ProjectOverviewMetadataComponent { this.actions.getSubjects(project.id, ResourceType.Project); this.actions.getProjectSubmissions(project.id); this.actions.getLicense(project.licenseId); + this.actions.getCedarRecords(project.id, ResourceType.Project); + this.actions.getCedarTemplates(); } }); } From 95830e59066c7da9ffa19755a05a467b333850c2 Mon Sep 17 00:00:00 2001 From: Vlad0n20 Date: Mon, 15 Jun 2026 12:31:41 +0200 Subject: [PATCH 27/32] fix(ENG-11346): show placeholder in cedar-derived filter dropdowns --- .../search-filters/search-filters.component.html | 2 +- .../search-filters.component.spec.ts | 14 ++++++++++++++ .../search-filters/search-filters.component.ts | 4 ++++ src/assets/i18n/en.json | 3 ++- 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/app/shared/components/search-filters/search-filters.component.html b/src/app/shared/components/search-filters/search-filters.component.html index 513133e26..67db13ec0 100644 --- a/src/app/shared/components/search-filters/search-filters.component.html +++ b/src/app/shared/components/search-filters/search-filters.component.html @@ -24,7 +24,7 @@ { expect(component.selectedOptionValues()).toEqual({}); }); + + it('should return specific placeholder key for known filter keys', () => { + fixture.detectChanges(); + + const filter = { key: 'subject', label: 'Subject' } as DiscoverableFilter; + expect(component.getPlaceholderKey(filter)).toBe('common.search.filterPlaceholders.subject'); + }); + + it('should return generic placeholder key for CEDAR-derived filters not in FILTER_PLACEHOLDERS', () => { + fixture.detectChanges(); + + const cedarFilter = { key: 'Collected Type Choices', label: 'Collected Type Choices' } as DiscoverableFilter; + expect(component.getPlaceholderKey(cedarFilter)).toBe('common.search.filterPlaceholders.generic'); + }); }); diff --git a/src/app/shared/components/search-filters/search-filters.component.ts b/src/app/shared/components/search-filters/search-filters.component.ts index f7f8991f4..1b26fb1e7 100644 --- a/src/app/shared/components/search-filters/search-filters.component.ts +++ b/src/app/shared/components/search-filters/search-filters.component.ts @@ -128,6 +128,10 @@ export class SearchFiltersComponent { this.filterOptionSelected.emit({ filter, filterOption: isChecked ? [option] : [] }); } + getPlaceholderKey(filter: DiscoverableFilter): string { + return FILTER_PLACEHOLDERS[filter.key] || 'common.search.filterPlaceholders.generic'; + } + private scrollPanelIntoView(key: string) { of(key) .pipe(delay(this.SCROLL_DELAY_MS), takeUntilDestroyed(this.destroyRef)) diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 3bc06149b..be4df2ce8 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -105,7 +105,8 @@ "isPartOfCollection": "Select collection", "dateCreated": "Select date", "creator": "Creator name", - "resourceType": "Select resource type" + "resourceType": "Select resource type", + "generic": "Select {{label}}" } }, "sort": { From f0167e66e71c2d5b316a833e6c511362841a86cd Mon Sep 17 00:00:00 2001 From: Vlad0n20 Date: Mon, 15 Jun 2026 17:39:20 +0200 Subject: [PATCH 28/32] fix(ENG-11346): use nullish coalescing operator --- .../components/search-filters/search-filters.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/shared/components/search-filters/search-filters.component.ts b/src/app/shared/components/search-filters/search-filters.component.ts index 1b26fb1e7..bac738417 100644 --- a/src/app/shared/components/search-filters/search-filters.component.ts +++ b/src/app/shared/components/search-filters/search-filters.component.ts @@ -129,7 +129,7 @@ export class SearchFiltersComponent { } getPlaceholderKey(filter: DiscoverableFilter): string { - return FILTER_PLACEHOLDERS[filter.key] || 'common.search.filterPlaceholders.generic'; + return FILTER_PLACEHOLDERS[filter.key] ?? 'common.search.filterPlaceholders.generic'; } private scrollPanelIntoView(key: string) { From 2a23aec9adb7cd862c193cfaca4188a1ec21d6d7 Mon Sep 17 00:00:00 2001 From: Yuhuai Liu Date: Wed, 17 Jun 2026 07:57:51 -0400 Subject: [PATCH 29/32] feat(es2): Fix overview page dropdown --- .../shared/stores/collections/collections.state.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/app/shared/stores/collections/collections.state.ts b/src/app/shared/stores/collections/collections.state.ts index c419afe56..55a55c089 100644 --- a/src/app/shared/stores/collections/collections.state.ts +++ b/src/app/shared/stores/collections/collections.state.ts @@ -1,6 +1,6 @@ import { Action, State, StateContext } from '@ngxs/store'; -import { catchError, forkJoin, of, switchMap, tap } from 'rxjs'; +import { catchError, forkJoin, map, of, switchMap, tap } from 'rxjs'; import { inject, Injectable } from '@angular/core'; import { Router } from '@angular/router'; @@ -116,7 +116,17 @@ export class CollectionsState { } const submissionRequests = collections.map((collection) => - this.collectionsService.fetchCurrentSubmission(action.projectId, collection.id) + this.collectionsService.fetchCurrentSubmission(action.projectId, collection.id).pipe( + switchMap((submission) => + this.collectionsService.getCollectionProvider(submission.collectionId).pipe( + map((provider) => ({ + ...submission, + requiredMetadataTemplateId: provider.requiredMetadataTemplate?.id ?? null, + })), + catchError(() => of(submission)) + ) + ) + ) ); return forkJoin(submissionRequests); From 1213c904a990e742d2cf15d1f22ab7a1bfd671a1 Mon Sep 17 00:00:00 2001 From: Vlad0n20 Date: Wed, 17 Jun 2026 17:55:37 +0200 Subject: [PATCH 30/32] fix(ENG-11333): fix single value filtering --- .../global-search/global-search.state.spec.ts | 34 +++++++++++++++++++ .../global-search/global-search.state.ts | 7 ++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/app/shared/stores/global-search/global-search.state.spec.ts b/src/app/shared/stores/global-search/global-search.state.spec.ts index dd78a40d0..e204398a9 100644 --- a/src/app/shared/stores/global-search/global-search.state.spec.ts +++ b/src/app/shared/stores/global-search/global-search.state.spec.ts @@ -262,6 +262,40 @@ describe('GlobalSearchState', () => { const params = mockGetResources.mock.calls[0][0]; expect(params['cardSearchFilter[defaultKey][]']).toBe('default-value'); + expect(params['cardSearchFilter[defaultKey][any-of]']).toBeUndefined(); + }); + + it('should AND the default value with an any-of clause for an explicitly selected value', () => { + const { store, mockGetResources } = setup(); + + store.dispatch(new SetDefaultFilterValue('defaultKey', 'default-value')); + store.dispatch( + new UpdateSelectedFilterOption('defaultKey', [ + { label: 'A', value: 'selected-value', cardSearchResultCount: null }, + ]) + ); + store.dispatch(new FetchResources()); + + const params = mockGetResources.mock.calls[0][0]; + expect(params['cardSearchFilter[defaultKey][]']).toBe('default-value'); + expect(params['cardSearchFilter[defaultKey][any-of]']).toBe('selected-value'); + }); + + it('should OR multiple selected values together via a single any-of clause', () => { + const { store, mockGetResources } = setup(); + + store.dispatch(new SetDefaultFilterValue('defaultKey', 'default-value')); + store.dispatch( + new UpdateSelectedFilterOption('defaultKey', [ + { label: 'A', value: 'selected-value-1', cardSearchResultCount: null }, + { label: 'B', value: 'selected-value-2', cardSearchResultCount: null }, + ]) + ); + store.dispatch(new FetchResources()); + + const params = mockGetResources.mock.calls[0][0]; + expect(params['cardSearchFilter[defaultKey][]']).toBe('default-value'); + expect(params['cardSearchFilter[defaultKey][any-of]']).toBe('selected-value-1,selected-value-2'); }); }); }); diff --git a/src/app/shared/stores/global-search/global-search.state.ts b/src/app/shared/stores/global-search/global-search.state.ts index bc7bb2710..eb94222ea 100644 --- a/src/app/shared/stores/global-search/global-search.state.ts +++ b/src/app/shared/stores/global-search/global-search.state.ts @@ -327,7 +327,7 @@ export class GlobalSearchState { let hasCedarFilters = state.extraFilters.length > 0; Object.entries(state.selectedFilterOptions).forEach(([key, options]) => { - if (key in state.defaultFilterOptions) return; + if (key in state.defaultFilterOptions && options.length === 0) return; const filter = state.filters.find((f) => f.key === key) ?? state.extraFilters.find((f) => f.key === key); if (filter?.cedarPropertyIri) { @@ -344,7 +344,10 @@ export class GlobalSearchState { filtersParams[`cardSearchFilter[${key}][is-present]`] = firstOptionValue; } } else { - filtersParams[`cardSearchFilter[${key}][]`] = options.map((option) => option.value); + const selectedValues = options.map((option) => option.value); + if (selectedValues.length) { + filtersParams[`cardSearchFilter[${key}][any-of]`] = selectedValues.join(','); + } } } }); From d5315bf8b794c0c20646d44cc3a6c46ced16c225 Mon Sep 17 00:00:00 2001 From: Yuhuai Liu Date: Wed, 17 Jun 2026 15:11:46 -0400 Subject: [PATCH 31/32] feat(es2): Get rid of artifact viewer --- .../overview-collections.component.html | 31 +++++------- .../overview-collections.component.ts | 50 ++++++++++++++----- 2 files changed, 49 insertions(+), 32 deletions(-) diff --git a/src/app/features/project/overview/components/overview-collections/overview-collections.component.html b/src/app/features/project/overview/components/overview-collections/overview-collections.component.html index 2b7e65d86..2904e8c9c 100644 --- a/src/app/features/project/overview/components/overview-collections/overview-collections.component.html +++ b/src/app/features/project/overview/components/overview-collections/overview-collections.component.html @@ -33,26 +33,19 @@

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

@let cedarRecord = cedarRecordByTemplateId().get(templateId) ?? null; @let cedarTemplate = cedarTemplateById().get(templateId) ?? null; - @if (isCedarMode() && cedarRecord && cedarTemplate?.attributes?.template) { -
- -
- } @else { - @let attributes = getSubmissionAttributes(submission); + @let attributes = + isCedarMode() && cedarRecord && cedarTemplate?.attributes?.template + ? getCedarAttributes(cedarRecord, cedarTemplate!) + : getSubmissionAttributes(submission); - @if (attributes.length) { -
- @for (attribute of attributes; track attribute.key) { -

- {{ attribute.label }}: {{ attribute.value }} -

- } -
- } + @if (attributes.length) { +
+ @for (attribute of attributes; track attribute.key) { +

+ {{ attribute.label }}: {{ attribute.value }} +

+ } +
} diff --git a/src/app/features/project/overview/components/overview-collections/overview-collections.component.ts b/src/app/features/project/overview/components/overview-collections/overview-collections.component.ts index a9c51aa97..fdc6cbe7a 100644 --- a/src/app/features/project/overview/components/overview-collections/overview-collections.component.ts +++ b/src/app/features/project/overview/components/overview-collections/overview-collections.component.ts @@ -5,18 +5,10 @@ import { Button } from 'primeng/button'; import { Skeleton } from 'primeng/skeleton'; import { Tag } from 'primeng/tag'; -import { - ChangeDetectionStrategy, - Component, - computed, - CUSTOM_ELEMENTS_SCHEMA, - input, - ViewEncapsulation, -} from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; import { RouterLink } from '@angular/router'; import { collectionFilterNames } from '@osf/features/collections/constants'; -import { CEDAR_VIEWER_CONFIG } from '@osf/features/metadata/constants'; import { CedarMetadataDataTemplateJsonApi, CedarMetadataRecordData } from '@osf/features/metadata/models'; import { StopPropagationDirective } from '@osf/shared/directives/stop-propagation.directive'; import { CollectionSubmission } from '@osf/shared/models/collections/collections.model'; @@ -41,8 +33,6 @@ import { CollectionStatusSeverityPipe } from '@osf/shared/pipes/collection-statu templateUrl: './overview-collections.component.html', styleUrl: './overview-collections.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - schemas: [CUSTOM_ELEMENTS_SCHEMA], - encapsulation: ViewEncapsulation.None, }) export class OverviewCollectionsComponent { projectSubmissions = input(null); @@ -51,8 +41,6 @@ export class OverviewCollectionsComponent { cedarRecords = input(null); cedarTemplates = input(null); - cedarViewerConfig = CEDAR_VIEWER_CONFIG; - cedarRecordByTemplateId = computed(() => { const records = this.cedarRecords(); return new Map( @@ -85,4 +73,40 @@ export class OverviewCollectionsComponent { return attributes; } + + getCedarAttributes(record: CedarMetadataRecordData, template: CedarMetadataDataTemplateJsonApi): KeyValueModel[] { + const { order, propertyLabels } = template.attributes.template._ui; + const metadata = record.attributes.metadata as Record; + const attributes: KeyValueModel[] = []; + + for (const key of order) { + const label = propertyLabels[key]; + const value = this.formatCedarValue(metadata[key]); + if (label && value) { + attributes.push({ key, label, value }); + } + } + + return attributes; + } + + private formatCedarValue(value: unknown): string { + if (value == null) return ''; + + if (Array.isArray(value)) { + return value + .map((item) => this.formatCedarValue(item)) + .filter(Boolean) + .join(', '); + } + + if (typeof value === 'object') { + const obj = value as Record; + if ('@value' in obj && obj['@value'] != null) return String(obj['@value']); + if ('rdfs:label' in obj && obj['rdfs:label'] != null) return String(obj['rdfs:label']); + return ''; + } + + return String(value); + } } From c2b72371448eae459253fa8f44bb540c2ee6414e Mon Sep 17 00:00:00 2001 From: Yuhuai Liu Date: Wed, 17 Jun 2026 15:17:28 -0400 Subject: [PATCH 32/32] feat(es2): update tests --- .../overview-collections.component.spec.ts | 42 ++++++++++++------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/src/app/features/project/overview/components/overview-collections/overview-collections.component.spec.ts b/src/app/features/project/overview/components/overview-collections/overview-collections.component.spec.ts index c279a7aef..57e2e414b 100644 --- a/src/app/features/project/overview/components/overview-collections/overview-collections.component.spec.ts +++ b/src/app/features/project/overview/components/overview-collections/overview-collections.component.spec.ts @@ -2,7 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; import { collectionFilterNames } from '@osf/features/collections/constants'; -import { CedarMetadataDataTemplateJsonApi, CedarMetadataRecordData } from '@osf/features/metadata/models'; +import { CedarMetadataDataTemplateJsonApi } from '@osf/features/metadata/models'; import { CollectionSubmission } from '@osf/shared/models/collections/collections.model'; import { CEDAR_METADATA_DATA_TEMPLATE_JSON_API_MOCK } from '@testing/mocks/cedar-metadata-data-template-json-api.mock'; @@ -96,47 +96,49 @@ describe('OverviewCollectionsComponent', () => { expect(typeof statusAttr?.value).toBe('string'); }); - it('should render cedar-artifact-viewer when isCedarMode is true with matching record and template', async () => { + it('should display cedar attributes as key-value pairs when isCedarMode is true with matching record and template', async () => { const cedarSubmission: CollectionSubmission = { ...MOCK_COLLECTION_SUBMISSION_EMPTY_FILTERS, requiredMetadataTemplateId: 'template-1', }; - const cedarRecord: CedarMetadataRecordData = MOCK_CEDAR_METADATA_RECORD_DATA; const cedarTemplate: CedarMetadataDataTemplateJsonApi = CEDAR_METADATA_DATA_TEMPLATE_JSON_API_MOCK as CedarMetadataDataTemplateJsonApi; fixture.componentRef.setInput('projectSubmissions', [cedarSubmission]); fixture.componentRef.setInput('isCedarMode', true); - fixture.componentRef.setInput('cedarRecords', [cedarRecord]); + fixture.componentRef.setInput('cedarRecords', [MOCK_CEDAR_METADATA_RECORD_DATA]); fixture.componentRef.setInput('cedarTemplates', [cedarTemplate]); fixture.detectChanges(); await fixture.whenStable(); - const viewer = fixture.nativeElement.querySelector('cedar-artifact-viewer'); - expect(viewer).toBeTruthy(); + const paragraphs = fixture.nativeElement.querySelectorAll('p.font-normal'); + expect(paragraphs.length).toBeGreaterThan(0); + expect(fixture.nativeElement.textContent).toContain('Project Name'); + expect(fixture.nativeElement.textContent).toContain('Test Project Name'); }); - it('should not render cedar-artifact-viewer when isCedarMode is false', async () => { + it('should display submission attributes when isCedarMode is false', async () => { const cedarSubmission: CollectionSubmission = { ...MOCK_COLLECTION_SUBMISSION_WITH_FILTERS, requiredMetadataTemplateId: 'template-1', }; - const cedarRecord: CedarMetadataRecordData = MOCK_CEDAR_METADATA_RECORD_DATA; const cedarTemplate: CedarMetadataDataTemplateJsonApi = CEDAR_METADATA_DATA_TEMPLATE_JSON_API_MOCK as CedarMetadataDataTemplateJsonApi; fixture.componentRef.setInput('projectSubmissions', [cedarSubmission]); fixture.componentRef.setInput('isCedarMode', false); - fixture.componentRef.setInput('cedarRecords', [cedarRecord]); + fixture.componentRef.setInput('cedarRecords', [MOCK_CEDAR_METADATA_RECORD_DATA]); fixture.componentRef.setInput('cedarTemplates', [cedarTemplate]); fixture.detectChanges(); await fixture.whenStable(); - const viewer = fixture.nativeElement.querySelector('cedar-artifact-viewer'); - expect(viewer).toBeNull(); + const attributes = component.getSubmissionAttributes(cedarSubmission); + expect(attributes.length).toBeGreaterThan(0); + const paragraphs = fixture.nativeElement.querySelectorAll('p.font-normal'); + expect(paragraphs.length).toBe(attributes.length); }); - it('should show traditional attributes when isCedarMode is true but no matching record', async () => { + it('should fall back to submission attributes when isCedarMode is true but no matching record', async () => { const cedarSubmission: CollectionSubmission = { ...MOCK_COLLECTION_SUBMISSION_WITH_FILTERS, requiredMetadataTemplateId: 'non-existent-template', @@ -149,9 +151,19 @@ describe('OverviewCollectionsComponent', () => { fixture.detectChanges(); await fixture.whenStable(); - const viewer = fixture.nativeElement.querySelector('cedar-artifact-viewer'); - expect(viewer).toBeNull(); - expect(component.getSubmissionAttributes(cedarSubmission).length).toBeGreaterThan(0); + const attributes = component.getSubmissionAttributes(cedarSubmission); + expect(attributes.length).toBeGreaterThan(0); + const paragraphs = fixture.nativeElement.querySelectorAll('p.font-normal'); + expect(paragraphs.length).toBe(attributes.length); + }); + + it('should extract key-value pairs from a cedar record using template field order and labels', () => { + const cedarTemplate: CedarMetadataDataTemplateJsonApi = + CEDAR_METADATA_DATA_TEMPLATE_JSON_API_MOCK as CedarMetadataDataTemplateJsonApi; + + const result = component.getCedarAttributes(MOCK_CEDAR_METADATA_RECORD_DATA, cedarTemplate); + + expect(result).toContainEqual({ key: 'Project Name', label: 'Project Name', value: 'Test Project Name' }); }); it('should compute empty cedarRecordByTemplateId map when cedarRecords is null', () => {