From 874450feb6c34c6134e4ada92c8e8ea9595d2edb Mon Sep 17 00:00:00 2001 From: Vlad0n20 Date: Tue, 28 Apr 2026 12:36:05 +0300 Subject: [PATCH] feat(ENG-9818): collection search with shtrove and cedar filters --- .../collections-discover.component.html | 8 +- .../collections-discover.component.spec.ts | 300 +++++++++++++----- .../collections-discover.component.ts | 97 ++++-- .../cedar-template-filter.mapper.spec.ts | 83 +++++ .../filters/cedar-template-filter.mapper.ts | 16 + .../global-search/global-search.actions.ts | 8 +- .../global-search/global-search.model.ts | 2 + .../global-search/global-search.state.ts | 12 +- .../providers/environment.token.mock.ts | 1 + 9 files changed, 418 insertions(+), 109 deletions(-) create mode 100644 src/app/shared/mappers/filters/cedar-template-filter.mapper.spec.ts create mode 100644 src/app/shared/mappers/filters/cedar-template-filter.mapper.ts diff --git a/src/app/features/collections/components/collections-discover/collections-discover.component.html b/src/app/features/collections/components/collections-discover/collections-discover.component.html index 1e9261f03..1d2fe31b5 100644 --- a/src/app/features/collections/components/collections-discover/collections-discover.component.html +++ b/src/app/features/collections/components/collections-discover/collections-discover.component.html @@ -37,7 +37,13 @@

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