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..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 @@ -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,20 @@ 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 (!cedarData || !projectId || !templateId) return of(null); + + const existingId = this.existingCedarRecord()?.id; + return existingId + ? this.actions.updateCedarRecord(cedarData, existingId, projectId, ResourceType.Project) + : this.actions.createCedarRecord(cedarData, projectId, ResourceType.Project); + } + private initializeProvider(): void { const id = this.route.snapshot.paramMap.get('providerId'); if (!id) { @@ -286,6 +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 96c1f74cd..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 @@ -6,18 +6,21 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormControl, FormGroup, Validators } from '@angular/forms'; import { AddToCollectionSteps } from '@osf/features/collections/enums'; +import { AddToCollectionSelectors } from '@osf/features/collections/store/add-to-collection'; +import { CedarMetadataDataTemplateJsonApi, CedarMetadataRecordData } from '@osf/features/metadata/models'; import { CollectionsSelectors } from '@shared/stores/collections'; +import { MOCK_CEDAR_TEMPLATE } from '@testing/data/collections/cedar-metadata.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { provideMockStore } from '@testing/providers/store-provider.mock'; import { CollectionMetadataStepComponent } from './collection-metadata-step.component'; -describe.skip('CollectionMetadataStepComponent', () => { +describe('CollectionMetadataStepComponent', () => { let component: CollectionMetadataStepComponent; let fixture: ComponentFixture; - beforeEach(() => { + function setup(isCedarMode = false, cedarTemplate: CedarMetadataDataTemplateJsonApi | null = null) { TestBed.configureTestingModule({ imports: [CollectionMetadataStepComponent, MockComponents(StepPanel, Step, StepItem)], providers: [ @@ -27,6 +30,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 +43,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 +63,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 +100,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 +115,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 +125,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 unknown as CedarMetadataRecordData['attributes']['metadata'], + is_published: false, + }, + relationships: { + template: { data: { type: 'cedar-metadata-templates', id: 'template-1' } }, + target: { data: { type: 'nodes', id: 'node-1' } }, + }, + }; + fixture.componentRef.setInput('existingCedarRecord', existingRecord); + fixture.detectChanges(); + + component.collectionMetadataSaved.set(true); + component.handleDiscardChanges(); + + expect(component.collectionMetadataSaved()).toBe(false); + }); + + it('should populate cedarFormData from existingCedarRecord', () => { + const existingRecord: CedarMetadataRecordData = { + attributes: { + metadata: { field: 'existing' } as unknown as CedarMetadataRecordData['attributes']['metadata'], + is_published: true, + }, + relationships: { + template: { data: { type: 'cedar-metadata-templates', id: 'template-1' } }, + target: { data: { type: 'nodes', id: 'node-1' } }, + }, + }; + fixture.componentRef.setInput('existingCedarRecord', existingRecord); + fixture.detectChanges(); + + expect(component.cedarFormData()).toEqual({ field: 'existing' }); + }); + + it('should emit cedarDataSaved when handleSaveCedarMetadata is called without editor', () => { + const cedarDataSavedSpy = vi.spyOn(component.cedarDataSaved, 'emit'); + const stepChangeSpy = vi.spyOn(component.stepChange, 'emit'); + + component.handleSaveCedarMetadata(); + + expect(cedarDataSavedSpy).not.toHaveBeenCalled(); + expect(stepChangeSpy).not.toHaveBeenCalled(); + }); + + it('should handle onCedarChange event', () => { + const mockMetadata = { field: 'changed' }; + const mockEditor = { currentMetadata: mockMetadata } as unknown as EventTarget; + const mockEvent = new CustomEvent('change'); + Object.defineProperty(mockEvent, 'target', { value: mockEditor }); + + component.onCedarChange(mockEvent); + + expect(component.cedarFormData()).toEqual(mockMetadata); + }); + + it('should not call handleSaveCedarMetadata without template', () => { + fixture.componentRef.setInput('cedarTemplate', null); + fixture.detectChanges(); + + const cedarDataSavedSpy = vi.spyOn(component.cedarDataSaved, 'emit'); + + component.handleSaveCedarMetadata(); + + expect(cedarDataSavedSpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.ts b/src/app/features/collections/components/add-to-collection/collection-metadata-step/collection-metadata-step.component.ts index acb6a1d0b..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,16 +66,27 @@ export class CollectionMetadataStepComponent { targetStepValue = input.required(); isDisabled = input.required(); primaryCollectionId = input(); + isCedarMode = input(false); + cedarTemplate = input(null); + existingCedarRecord = input(null); stepChange = output(); metadataSaved = output(); + cedarDataSaved = output(); collectionMetadataForm = signal(new FormGroup({})); collectionMetadataSaved = signal(false); originalFormValues = signal>({}); formPopulatedFromSubmission = signal(false); + cedarFormData = signal>({}); - actions = createDispatchMap({ getCollectionDetails: GetCollectionDetails }); + cedarConfig = CEDAR_CONFIG; + cedarViewerConfig = CEDAR_VIEWER_CONFIG; + + cedarEditor = viewChild>('cedarEditor'); + cedarViewer = viewChild>('cedarViewer'); + + private readonly actions = createDispatchMap({ getCollectionDetails: GetCollectionDetails }); constructor() { this.setupEffects(); @@ -65,6 +97,19 @@ export class CollectionMetadataStepComponent { } handleDiscardChanges() { + if (this.isCedarMode()) { + const record = this.existingCedarRecord(); + this.cedarFormData.set( + record?.attributes?.metadata ? (record.attributes.metadata as Record) : {} + ); + const editor = this.cedarEditor()?.nativeElement; + if (editor) { + editor.instanceObject = this.cedarFormData(); + } + this.collectionMetadataSaved.set(false); + return; + } + const form = this.collectionMetadataForm(); const originalValues = this.originalFormValues(); @@ -85,6 +130,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.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..df61863f3 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,259 @@ +import { Store } from '@ngxs/store'; + import { MockComponents, MockProvider } from 'ng-mocks'; 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; - it('should initialize with default values', () => { - expect(component.providerId()).toBe('provider-1'); - expect(component.searchControl.value).toBe(''); + 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)], + }, }); - 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 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 compute primary collection id', () => { - expect(component.primaryCollectionId()).toBe(MOCK_PROVIDER.primaryCollection?.id); + 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 jest.Mock).mockClear(); + + localComponent.onSearchTriggered('my query'); + + const calls = (localStore.dispatch as jest.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 jest.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 jest.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 jest.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 jest.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/features/metadata/components/metadata-collection-item/metadata-collection-item.component.html b/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.html index cf05872c9..d7a384a20 100644 --- a/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.html +++ b/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.html @@ -22,6 +22,16 @@ +@if (showCedarViewer()) { +
+ +
+} + @if (showAttributes()) {
@for (attribute of attributes(); track attribute.key) { diff --git a/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.spec.ts b/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.spec.ts index 106fea114..1ed5237ae 100644 --- a/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.spec.ts +++ b/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.spec.ts @@ -4,32 +4,23 @@ import { provideRouter } from '@angular/router'; import { CollectionSubmissionReviewState } from '@osf/shared/enums/collection-submission-review-state.enum'; import { CollectionSubmission } from '@osf/shared/models/collections/collections.model'; +import { + MOCK_CEDAR_RECORD, + MOCK_CEDAR_SUBMISSION, + MOCK_CEDAR_TEMPLATE, +} from '@testing/data/collections/cedar-metadata.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { MetadataCollectionItemComponent } from './metadata-collection-item.component'; +const mockSubmission: CollectionSubmission = MOCK_CEDAR_SUBMISSION; +const mockCedarTemplate = MOCK_CEDAR_TEMPLATE; +const mockCedarRecord = MOCK_CEDAR_RECORD; + describe('MetadataCollectionItemComponent', () => { let component: MetadataCollectionItemComponent; let fixture: ComponentFixture; - const mockSubmission: CollectionSubmission = { - id: '1', - type: 'collection-submission', - collectionTitle: 'Test Collection', - collectionId: 'collection-123', - reviewsState: CollectionSubmissionReviewState.Pending, - collectedType: 'preprint', - status: 'pending', - volume: '1', - issue: '1', - programArea: 'Science', - schoolType: 'University', - studyDesign: 'Experimental', - dataType: 'Quantitative', - disease: 'Cancer', - gradeLevels: 'Graduate', - }; - beforeEach(() => { TestBed.configureTestingModule({ imports: [MetadataCollectionItemComponent], @@ -149,4 +140,76 @@ describe('MetadataCollectionItemComponent', () => { const attributesSection = fixture.nativeElement.querySelector('.flex.flex-column.gap-2.mt-2'); expect(attributesSection).toBeFalsy(); }); + + describe('CEDAR mode', () => { + it('should not show cedar viewer when isCedarMode is false', () => { + fixture.componentRef.setInput('isCedarMode', false); + fixture.componentRef.setInput('cedarRecord', mockCedarRecord); + fixture.componentRef.setInput('cedarTemplate', mockCedarTemplate); + fixture.detectChanges(); + + expect(component.showCedarViewer()).toBe(false); + }); + + it('should not show cedar viewer when cedarRecord is null', () => { + fixture.componentRef.setInput('isCedarMode', true); + fixture.componentRef.setInput('cedarRecord', null); + fixture.componentRef.setInput('cedarTemplate', mockCedarTemplate); + fixture.detectChanges(); + + expect(component.showCedarViewer()).toBe(false); + }); + + it('should not show cedar viewer when cedarTemplate is null', () => { + fixture.componentRef.setInput('isCedarMode', true); + fixture.componentRef.setInput('cedarRecord', mockCedarRecord); + fixture.componentRef.setInput('cedarTemplate', null); + fixture.detectChanges(); + + expect(component.showCedarViewer()).toBe(false); + }); + + it('should show cedar viewer when isCedarMode, record, and template are provided', () => { + fixture.componentRef.setInput('isCedarMode', true); + fixture.componentRef.setInput('cedarRecord', mockCedarRecord); + fixture.componentRef.setInput('cedarTemplate', mockCedarTemplate); + fixture.detectChanges(); + + expect(component.showCedarViewer()).toBe(true); + }); + + it('should not show cedar viewer when submission is removed', () => { + fixture.componentRef.setInput('isCedarMode', true); + fixture.componentRef.setInput('cedarRecord', mockCedarRecord); + fixture.componentRef.setInput('cedarTemplate', mockCedarTemplate); + fixture.componentRef.setInput('submission', { + ...mockSubmission, + reviewsState: CollectionSubmissionReviewState.Removed, + }); + fixture.detectChanges(); + + expect(component.showCedarViewer()).toBe(false); + }); + + it('should not show attributes in cedar mode', () => { + fixture.componentRef.setInput('isCedarMode', true); + fixture.detectChanges(); + + expect(component.showAttributes()).toBe(false); + }); + + it('should compute cedarMetadata from record', () => { + fixture.componentRef.setInput('cedarRecord', mockCedarRecord); + fixture.detectChanges(); + + expect(component.cedarMetadata()).toEqual({ field: 'value' }); + }); + + it('should return empty object for cedarMetadata when no record', () => { + fixture.componentRef.setInput('cedarRecord', null); + fixture.detectChanges(); + + expect(component.cedarMetadata()).toEqual({}); + }); + }); }); diff --git a/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.ts b/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.ts index 1c023afd9..c9d700248 100644 --- a/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.ts +++ b/src/app/features/metadata/components/metadata-collection-item/metadata-collection-item.component.ts @@ -3,10 +3,19 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Tag } from 'primeng/tag'; -import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + computed, + CUSTOM_ELEMENTS_SCHEMA, + input, + ViewEncapsulation, +} from '@angular/core'; import { RouterLink } from '@angular/router'; import { collectionFilterNames } from '@osf/features/collections/constants'; +import { CEDAR_VIEWER_CONFIG } from '@osf/features/metadata/constants'; +import { CedarMetadataDataTemplateJsonApi, CedarMetadataRecordData } from '@osf/features/metadata/models'; import { CollectionSubmissionReviewState } from '@osf/shared/enums/collection-submission-review-state.enum'; import { CollectionSubmission } from '@osf/shared/models/collections/collections.model'; import { KeyValueModel } from '@osf/shared/models/common/key-value.model'; @@ -18,11 +27,18 @@ import { CollectionStatusSeverityPipe } from '@osf/shared/pipes/collection-statu templateUrl: './metadata-collection-item.component.html', styleUrl: './metadata-collection-item.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + encapsulation: ViewEncapsulation.None, }) export class MetadataCollectionItemComponent { readonly CollectionSubmissionReviewState = CollectionSubmissionReviewState; submission = input.required(); + isCedarMode = input(false); + cedarRecord = input(null); + cedarTemplate = input(null); + + cedarViewerConfig = CEDAR_VIEWER_CONFIG; showSubmissionButton = computed(() => this.submission().reviewsState === CollectionSubmissionReviewState.Accepted); @@ -32,9 +48,25 @@ export class MetadataCollectionItemComponent { }); showAttributes = computed( - () => this.submission().reviewsState !== CollectionSubmissionReviewState.Removed && !!this.attributes().length + () => + !this.isCedarMode() && + this.submission().reviewsState !== CollectionSubmissionReviewState.Removed && + !!this.attributes().length + ); + + showCedarViewer = computed( + () => + this.isCedarMode() && + !!this.cedarRecord() && + !!this.cedarTemplate()?.attributes?.template && + this.submission().reviewsState !== CollectionSubmissionReviewState.Removed ); + cedarMetadata = computed(() => { + const record = this.cedarRecord(); + return record?.attributes?.metadata ? (record.attributes.metadata as Record) : {}; + }); + attributes = computed(() => { const submission = this.submission(); const attributes: KeyValueModel[] = []; diff --git a/src/app/features/metadata/components/metadata-collections/metadata-collections.component.html b/src/app/features/metadata/components/metadata-collections/metadata-collections.component.html index 2a135bdee..d9d0a0815 100644 --- a/src/app/features/metadata/components/metadata-collections/metadata-collections.component.html +++ b/src/app/features/metadata/components/metadata-collections/metadata-collections.component.html @@ -9,7 +9,12 @@

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

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

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

diff --git a/src/app/features/metadata/components/metadata-collections/metadata-collections.component.spec.ts b/src/app/features/metadata/components/metadata-collections/metadata-collections.component.spec.ts index bd9568d2c..9d446b991 100644 --- a/src/app/features/metadata/components/metadata-collections/metadata-collections.component.spec.ts +++ b/src/app/features/metadata/components/metadata-collections/metadata-collections.component.spec.ts @@ -4,12 +4,22 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { ActivatedRoute } from '@angular/router'; +import { + MOCK_CEDAR_RECORD, + MOCK_CEDAR_SUBMISSION, + MOCK_CEDAR_TEMPLATE, +} from '@testing/data/collections/cedar-metadata.mock'; import { MOCK_PROJECT_COLLECTION_SUBMISSIONS } from '@testing/data/collections/collection-submissions.mock'; import { provideOSFCore } from '@testing/osf.testing.provider'; import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock'; import { MetadataCollectionsComponent } from './metadata-collections.component'; +const mockTemplateId = MOCK_CEDAR_TEMPLATE.id; +const mockCedarTemplate = MOCK_CEDAR_TEMPLATE; +const mockCedarRecord = MOCK_CEDAR_RECORD; +const mockSubmissionsWithTemplate = [MOCK_CEDAR_SUBMISSION]; + describe('MetadataCollectionsComponent', () => { let component: MetadataCollectionsComponent; let fixture: ComponentFixture; @@ -53,4 +63,49 @@ describe('MetadataCollectionsComponent', () => { const content = fixture.nativeElement.textContent; expect(content).toContain('project.overview.metadata.noCollections'); }); + + it('should default isCedarMode to false', () => { + expect(component.isCedarMode()).toBe(false); + }); + + it('should build cedarRecordByTemplateId map from records', () => { + fixture.componentRef.setInput('cedarRecords', [mockCedarRecord]); + fixture.detectChanges(); + + const map = component.cedarRecordByTemplateId(); + expect(map.get(mockTemplateId)).toEqual(mockCedarRecord); + }); + + it('should build empty cedarRecordByTemplateId map when no records', () => { + fixture.componentRef.setInput('cedarRecords', null); + fixture.detectChanges(); + + expect(component.cedarRecordByTemplateId().size).toBe(0); + }); + + it('should build cedarTemplateById map from templates', () => { + fixture.componentRef.setInput('cedarTemplates', [mockCedarTemplate]); + fixture.detectChanges(); + + const map = component.cedarTemplateById(); + expect(map.get(mockTemplateId)).toEqual(mockCedarTemplate); + }); + + it('should build empty cedarTemplateById map when no templates', () => { + fixture.componentRef.setInput('cedarTemplates', null); + fixture.detectChanges(); + + expect(component.cedarTemplateById().size).toBe(0); + }); + + it('should pass matching cedarRecord to items in cedar mode', () => { + fixture.componentRef.setInput('isCedarMode', true); + fixture.componentRef.setInput('projectSubmissions', mockSubmissionsWithTemplate); + fixture.componentRef.setInput('cedarRecords', [mockCedarRecord]); + fixture.componentRef.setInput('cedarTemplates', [mockCedarTemplate]); + fixture.detectChanges(); + + const items = fixture.debugElement.queryAll(By.css('osf-metadata-collection-item')); + expect(items.length).toBe(1); + }); }); diff --git a/src/app/features/metadata/components/metadata-collections/metadata-collections.component.ts b/src/app/features/metadata/components/metadata-collections/metadata-collections.component.ts index affc90e98..950d7e2ac 100644 --- a/src/app/features/metadata/components/metadata-collections/metadata-collections.component.ts +++ b/src/app/features/metadata/components/metadata-collections/metadata-collections.component.ts @@ -3,8 +3,9 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Card } from 'primeng/card'; import { Skeleton } from 'primeng/skeleton'; -import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; +import { CedarMetadataDataTemplateJsonApi, CedarMetadataRecordData } from '@osf/features/metadata/models'; import { CollectionSubmission } from '@osf/shared/models/collections/collections.model'; import { MetadataCollectionItemComponent } from '../metadata-collection-item/metadata-collection-item.component'; @@ -19,4 +20,22 @@ import { MetadataCollectionItemComponent } from '../metadata-collection-item/met export class MetadataCollectionsComponent { projectSubmissions = input(null); isProjectSubmissionsLoading = input(false); + cedarRecords = input(null); + cedarTemplates = input(null); + isCedarMode = input(false); + + cedarRecordByTemplateId = computed(() => { + const records = this.cedarRecords(); + return new Map( + records?.flatMap((record) => { + const templateId = record.relationships?.template?.data?.id; + return templateId ? [[templateId, record] as const] : []; + }) ?? [] + ); + }); + + cedarTemplateById = computed(() => { + const templates = this.cedarTemplates(); + return new Map(templates?.map((t) => [t.id, t] as const) ?? []); + }); } diff --git a/src/app/features/metadata/metadata.component.html b/src/app/features/metadata/metadata.component.html index f49bd7ff0..e49c5490b 100644 --- a/src/app/features/metadata/metadata.component.html +++ b/src/app/features/metadata/metadata.component.html @@ -71,6 +71,9 @@ }
diff --git a/src/app/features/metadata/metadata.component.ts b/src/app/features/metadata/metadata.component.ts index ad6f68623..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 680b34a04..cd7711c26 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, }; } @@ -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/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/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/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/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", 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', +}; 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, }, };