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