-
-
+ @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 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/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()) {
+
@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/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",
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',
+};