@for (institution of institutions(); track $index) {
-
+
}
@if (!institutions().length) {
diff --git a/src/app/features/institutions/institutions.component.scss b/src/app/features/institutions/institutions.component.scss
deleted file mode 100644
index c84ec277..00000000
--- a/src/app/features/institutions/institutions.component.scss
+++ /dev/null
@@ -1,3 +0,0 @@
-.image {
- object-fit: contain;
-}
diff --git a/src/app/features/institutions/institutions.component.ts b/src/app/features/institutions/institutions.component.ts
index 17e1ea67..dba3a099 100644
--- a/src/app/features/institutions/institutions.component.ts
+++ b/src/app/features/institutions/institutions.component.ts
@@ -19,7 +19,7 @@ import {
} from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { FormControl } from '@angular/forms';
-import { ActivatedRoute, Router } from '@angular/router';
+import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { parseQueryFilterParams } from '@core/helpers';
import {
@@ -41,9 +41,9 @@ import { FetchInstitutions, InstitutionsSelectors } from '@shared/stores';
NgOptimizedImage,
CustomPaginatorComponent,
LoadingSpinnerComponent,
+ RouterLink,
],
templateUrl: './institutions.component.html',
- styleUrl: './institutions.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InstitutionsComponent {
diff --git a/src/app/features/institutions/institutions.routes.ts b/src/app/features/institutions/institutions.routes.ts
index 4d506a60..a07caff0 100644
--- a/src/app/features/institutions/institutions.routes.ts
+++ b/src/app/features/institutions/institutions.routes.ts
@@ -1,10 +1,20 @@
+import { provideStates } from '@ngxs/store';
+
import { Routes } from '@angular/router';
import { InstitutionsComponent } from '@osf/features/institutions/institutions.component';
+import { InstitutionsSearchState } from '@shared/stores';
+
+import { InstitutionsSearchComponent } from './pages';
export const routes: Routes = [
{
path: '',
component: InstitutionsComponent,
},
+ {
+ path: ':institution-id',
+ component: InstitutionsSearchComponent,
+ providers: [provideStates([InstitutionsSearchState])],
+ },
];
diff --git a/src/app/features/institutions/pages/index.ts b/src/app/features/institutions/pages/index.ts
new file mode 100644
index 00000000..f189ec9b
--- /dev/null
+++ b/src/app/features/institutions/pages/index.ts
@@ -0,0 +1 @@
+export { InstitutionsSearchComponent } from './institutions-search/institutions-search.component';
diff --git a/src/app/features/institutions/pages/institutions-search/institutions-search.component.html b/src/app/features/institutions/pages/institutions-search/institutions-search.component.html
new file mode 100644
index 00000000..5accea06
--- /dev/null
+++ b/src/app/features/institutions/pages/institutions-search/institutions-search.component.html
@@ -0,0 +1,98 @@
+
+ @if (isInstitutionLoading()) {
+
+
+
+ } @else {
+
+
+
![]()
+
+
{{ institution().name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @for (item of resourceTabOptions; track $index) {
+ {{ item.label | translate }}
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+
diff --git a/src/app/features/institutions/pages/institutions-search/institutions-search.component.scss b/src/app/features/institutions/pages/institutions-search/institutions-search.component.scss
new file mode 100644
index 00000000..da0c027b
--- /dev/null
+++ b/src/app/features/institutions/pages/institutions-search/institutions-search.component.scss
@@ -0,0 +1,5 @@
+:host {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+}
diff --git a/src/app/features/institutions/pages/institutions-search/institutions-search.component.spec.ts b/src/app/features/institutions/pages/institutions-search/institutions-search.component.spec.ts
new file mode 100644
index 00000000..c7068641
--- /dev/null
+++ b/src/app/features/institutions/pages/institutions-search/institutions-search.component.spec.ts
@@ -0,0 +1,22 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { InstitutionsSearchComponent } from './institutions-search.component';
+
+describe('InstitutionsSearchComponent', () => {
+ let component: InstitutionsSearchComponent;
+ let fixture: ComponentFixture
;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [InstitutionsSearchComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(InstitutionsSearchComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/app/features/institutions/pages/institutions-search/institutions-search.component.ts b/src/app/features/institutions/pages/institutions-search/institutions-search.component.ts
new file mode 100644
index 00000000..5fd29abe
--- /dev/null
+++ b/src/app/features/institutions/pages/institutions-search/institutions-search.component.ts
@@ -0,0 +1,328 @@
+import { createDispatchMap, select } from '@ngxs/store';
+
+import { TranslatePipe } from '@ngx-translate/core';
+
+import { AutoCompleteModule } from 'primeng/autocomplete';
+import { SafeHtmlPipe } from 'primeng/menu';
+import { Tabs, TabsModule } from 'primeng/tabs';
+
+import { debounceTime, distinctUntilChanged } from 'rxjs';
+
+import { NgOptimizedImage } from '@angular/common';
+import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, OnInit, signal } from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { FormControl, FormsModule } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+
+import {
+ LoadingSpinnerComponent,
+ ReusableFilterComponent,
+ SearchHelpTutorialComponent,
+ SearchInputComponent,
+} from '@shared/components';
+import { FilterChipsComponent } from '@shared/components/filter-chips/filter-chips.component';
+import { SearchResultsContainerComponent } from '@shared/components/search-results-container/search-results-container.component';
+import { SEARCH_TAB_OPTIONS } from '@shared/constants';
+import { ResourceTab } from '@shared/enums';
+import { DiscoverableFilter } from '@shared/models';
+import {
+ FetchInstitutionById,
+ FetchResources,
+ FetchResourcesByLink,
+ InstitutionsSearchSelectors,
+ LoadFilterOptions,
+ LoadFilterOptionsAndSetValues,
+ SetFilterValues,
+ UpdateFilterValue,
+ UpdateResourceType,
+ UpdateSortBy,
+} from '@shared/stores';
+
+@Component({
+ selector: 'osf-institutions-search',
+ imports: [
+ ReusableFilterComponent,
+ SearchResultsContainerComponent,
+ FilterChipsComponent,
+ AutoCompleteModule,
+ FormsModule,
+ Tabs,
+ TabsModule,
+ SearchHelpTutorialComponent,
+ SearchInputComponent,
+ TranslatePipe,
+ NgOptimizedImage,
+ LoadingSpinnerComponent,
+ SafeHtmlPipe,
+ ],
+ templateUrl: './institutions-search.component.html',
+ styleUrl: './institutions-search.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class InstitutionsSearchComponent implements OnInit {
+ private readonly route = inject(ActivatedRoute);
+ private readonly router = inject(Router);
+ private readonly destroyRef = inject(DestroyRef);
+
+ institution = select(InstitutionsSearchSelectors.getInstitution);
+ isInstitutionLoading = select(InstitutionsSearchSelectors.getInstitutionLoading);
+ resources = select(InstitutionsSearchSelectors.getResources);
+ isResourcesLoading = select(InstitutionsSearchSelectors.getResourcesLoading);
+ resourcesCount = select(InstitutionsSearchSelectors.getResourcesCount);
+ filters = select(InstitutionsSearchSelectors.getFilters);
+ selectedValues = select(InstitutionsSearchSelectors.getFilterValues);
+ selectedSort = select(InstitutionsSearchSelectors.getSortBy);
+ first = select(InstitutionsSearchSelectors.getFirst);
+ next = select(InstitutionsSearchSelectors.getNext);
+ previous = select(InstitutionsSearchSelectors.getPrevious);
+
+ private readonly actions = createDispatchMap({
+ fetchInstitution: FetchInstitutionById,
+ updateResourceType: UpdateResourceType,
+ updateSortBy: UpdateSortBy,
+ loadFilterOptions: LoadFilterOptions,
+ loadFilterOptionsAndSetValues: LoadFilterOptionsAndSetValues,
+ setFilterValues: SetFilterValues,
+ updateFilterValue: UpdateFilterValue,
+ fetchResourcesByLink: FetchResourcesByLink,
+ fetchResources: FetchResources,
+ });
+ protected readonly resourceTabOptions = SEARCH_TAB_OPTIONS;
+
+ private readonly tabUrlMap = new Map(
+ SEARCH_TAB_OPTIONS.map((option) => [option.value, option.label.split('.').pop()?.toLowerCase() || 'all'])
+ );
+
+ private readonly urlTabMap = new Map(
+ SEARCH_TAB_OPTIONS.map((option) => [option.label.split('.').pop()?.toLowerCase() || 'all', option.value])
+ );
+
+ protected searchControl = new FormControl('');
+ protected selectedTab: ResourceTab = ResourceTab.All;
+ protected currentStep = signal(0);
+ protected isFiltersOpen = signal(true);
+ protected isSortingOpen = signal(false);
+
+ readonly resourceTab = ResourceTab;
+ readonly resourceType = select(InstitutionsSearchSelectors.getResourceType);
+
+ readonly filterLabels = computed(() => {
+ const filtersData = this.filters();
+ const labels: Record = {};
+ filtersData.forEach((filter) => {
+ if (filter.key && filter.label) {
+ labels[filter.key] = filter.label;
+ }
+ });
+ return labels;
+ });
+
+ readonly filterOptions = computed(() => {
+ const filtersData = this.filters();
+ const options: Record = {};
+ filtersData.forEach((filter) => {
+ if (filter.key && filter.options) {
+ options[filter.key] = filter.options.map((opt) => ({
+ id: String(opt.value || ''),
+ value: String(opt.value || ''),
+ label: opt.label,
+ }));
+ }
+ });
+ return options;
+ });
+
+ ngOnInit(): void {
+ this.restoreFiltersFromUrl();
+ this.restoreTabFromUrl();
+ this.restoreSearchFromUrl();
+ this.handleSearch();
+
+ const institutionId = this.route.snapshot.params['institution-id'];
+ if (institutionId) {
+ this.actions.fetchInstitution(institutionId);
+ }
+ }
+
+ onLoadFilterOptions(event: { filterType: string; filter: DiscoverableFilter }): void {
+ this.actions.loadFilterOptions(event.filterType);
+ }
+
+ onFilterChanged(event: { filterType: string; value: string | null }): void {
+ this.actions.updateFilterValue(event.filterType, event.value);
+
+ const currentFilters = this.selectedValues();
+ const updatedFilters = {
+ ...currentFilters,
+ [event.filterType]: event.value,
+ };
+
+ Object.keys(updatedFilters).forEach((key) => {
+ if (!updatedFilters[key]) {
+ delete updatedFilters[key];
+ }
+ });
+
+ this.updateUrlWithFilters(updatedFilters);
+ }
+
+ showTutorial() {
+ this.currentStep.set(1);
+ }
+
+ onTabChange(index: ResourceTab): void {
+ this.selectedTab = index;
+ this.actions.updateResourceType(index);
+ this.updateUrlWithTab(index);
+ this.actions.fetchResources();
+ }
+
+ onSortChanged(sort: string): void {
+ this.actions.updateSortBy(sort);
+ this.actions.fetchResources();
+ }
+
+ onPageChanged(link: string): void {
+ this.actions.fetchResourcesByLink(link);
+ }
+
+ onFiltersToggled(): void {
+ this.isFiltersOpen.update((open) => !open);
+ this.isSortingOpen.set(false);
+ }
+
+ onSortingToggled(): void {
+ this.isSortingOpen.update((open) => !open);
+ this.isFiltersOpen.set(false);
+ }
+
+ onFilterChipRemoved(filterKey: string): void {
+ this.actions.updateFilterValue(filterKey, null);
+
+ const currentFilters = this.selectedValues();
+ const updatedFilters = { ...currentFilters };
+ delete updatedFilters[filterKey];
+ this.updateUrlWithFilters(updatedFilters);
+
+ this.actions.fetchResources();
+ }
+
+ onAllFiltersCleared(): void {
+ this.actions.setFilterValues({});
+
+ this.searchControl.setValue('', { emitEvent: false });
+ this.actions.updateFilterValue('search', '');
+
+ const queryParams: Record = { ...this.route.snapshot.queryParams };
+
+ Object.keys(queryParams).forEach((key) => {
+ if (key.startsWith('filter_')) {
+ delete queryParams[key];
+ }
+ });
+
+ delete queryParams['search'];
+
+ this.router.navigate([], {
+ relativeTo: this.route,
+ queryParams,
+ queryParamsHandling: 'replace',
+ replaceUrl: true,
+ });
+ }
+
+ private restoreFiltersFromUrl(): void {
+ const queryParams = this.route.snapshot.queryParams;
+ const filterValues: Record = {};
+
+ Object.keys(queryParams).forEach((key) => {
+ if (key.startsWith('filter_')) {
+ const filterKey = key.replace('filter_', '');
+ const filterValue = queryParams[key];
+ if (filterValue) {
+ filterValues[filterKey] = filterValue;
+ }
+ }
+ });
+
+ if (Object.keys(filterValues).length > 0) {
+ this.actions.loadFilterOptionsAndSetValues(filterValues);
+ }
+ }
+
+ private updateUrlWithFilters(filterValues: Record): void {
+ const queryParams: Record = { ...this.route.snapshot.queryParams };
+
+ Object.keys(queryParams).forEach((key) => {
+ if (key.startsWith('filter_')) {
+ delete queryParams[key];
+ }
+ });
+
+ Object.entries(filterValues).forEach(([key, value]) => {
+ if (value && value.trim() !== '') {
+ queryParams[`filter_${key}`] = value;
+ }
+ });
+
+ this.router.navigate([], {
+ relativeTo: this.route,
+ queryParams,
+ queryParamsHandling: 'replace',
+ replaceUrl: true,
+ });
+ }
+
+ private updateUrlWithTab(tab: ResourceTab): void {
+ const queryParams: Record = { ...this.route.snapshot.queryParams };
+
+ if (tab !== ResourceTab.All) {
+ queryParams['tab'] = this.tabUrlMap.get(tab) || 'all';
+ } else {
+ delete queryParams['tab'];
+ }
+
+ this.router.navigate([], {
+ relativeTo: this.route,
+ queryParams,
+ queryParamsHandling: 'replace',
+ replaceUrl: true,
+ });
+ }
+
+ private restoreTabFromUrl(): void {
+ const queryParams = this.route.snapshot.queryParams;
+ const tabString = queryParams['tab'];
+ if (tabString) {
+ const tab = this.urlTabMap.get(tabString);
+ if (tab !== undefined) {
+ this.selectedTab = tab;
+ this.actions.updateResourceType(tab);
+ }
+ }
+ }
+
+ private restoreSearchFromUrl(): void {
+ const queryParams = this.route.snapshot.queryParams;
+ const searchTerm = queryParams['search'];
+ if (searchTerm) {
+ this.searchControl.setValue(searchTerm, { emitEvent: false });
+ this.actions.updateFilterValue('search', searchTerm);
+ }
+ }
+
+ private handleSearch(): void {
+ this.searchControl.valueChanges
+ .pipe(debounceTime(1000), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef))
+ .subscribe({
+ next: (newValue) => {
+ this.actions.updateFilterValue('search', newValue);
+ this.router.navigate([], {
+ relativeTo: this.route,
+ queryParams: { search: newValue },
+ queryParamsHandling: 'merge',
+ });
+ },
+ });
+ }
+}
diff --git a/src/app/features/my-projects/my-projects.component.ts b/src/app/features/my-projects/my-projects.component.ts
index 19fcb632..35e71d56 100644
--- a/src/app/features/my-projects/my-projects.component.ts
+++ b/src/app/features/my-projects/my-projects.component.ts
@@ -31,7 +31,7 @@ import { SortOrder } from '@osf/shared/enums';
import { QueryParams, TableParameters, TabOption } from '@osf/shared/models';
import { IS_MEDIUM, IS_WEB, IS_XSMALL } from '@osf/shared/utils';
-import { GetUserInstitutions } from '../../shared/stores/institutions';
+import { FetchUserInstitutions } from '../../shared/stores/institutions';
import { CollectionsSelectors, GetBookmarksCollectionId } from '../collections/store';
import { MyProjectsItem, MyProjectsSearchFilters } from './models';
@@ -128,7 +128,7 @@ export class MyProjectsComponent implements OnInit {
}
ngOnInit(): void {
- this.#store.dispatch(new GetUserInstitutions());
+ this.#store.dispatch(new FetchUserInstitutions());
this.#store.dispatch(new GetBookmarksCollectionId());
}
diff --git a/src/app/features/search/mappers/search.mapper.ts b/src/app/features/search/mappers/search.mapper.ts
index f177c368..5d365a1e 100644
--- a/src/app/features/search/mappers/search.mapper.ts
+++ b/src/app/features/search/mappers/search.mapper.ts
@@ -39,5 +39,5 @@ export function MapResources(rawItem: ResourceItem): Resource {
hasMaterialsResource: !!rawItem?.hasMaterialsResource,
hasPapersResource: !!rawItem?.hasPapersResource,
hasSupplementalResource: !!rawItem?.hasSupplementalResource,
- };
+ } as Resource;
}
diff --git a/src/app/features/search/models/raw-models/index-card-search.model.ts b/src/app/features/search/models/raw-models/index-card-search.model.ts
index fc37d736..521332d1 100644
--- a/src/app/features/search/models/raw-models/index-card-search.model.ts
+++ b/src/app/features/search/models/raw-models/index-card-search.model.ts
@@ -1,10 +1,14 @@
import { ApiData, JsonApiResponse } from '@osf/core/models';
+import { AppliedFilter, RelatedPropertyPathAttributes } from '@shared/mappers';
import { ResourceItem } from './resource-response.model';
export type IndexCardSearch = JsonApiResponse<
{
- attributes: { totalResultCount: number };
+ attributes: {
+ totalResultCount: number;
+ cardSearchFilter?: AppliedFilter[];
+ };
relationships: {
searchResultPage: {
links: {
@@ -21,5 +25,8 @@ export type IndexCardSearch = JsonApiResponse<
};
};
},
- ApiData<{ resourceMetadata: ResourceItem }, null, null, null>[]
+ (
+ | ApiData<{ resourceMetadata: ResourceItem }, null, null, null>
+ | ApiData
+ )[]
>;
diff --git a/src/app/features/search/models/resources-data.model.ts b/src/app/features/search/models/resources-data.model.ts
index 3b708bc3..c9157d4b 100644
--- a/src/app/features/search/models/resources-data.model.ts
+++ b/src/app/features/search/models/resources-data.model.ts
@@ -1,7 +1,8 @@
-import { Resource } from '@osf/shared/models';
+import { DiscoverableFilter, Resource } from '@osf/shared/models';
export interface ResourcesData {
resources: Resource[];
+ filters: DiscoverableFilter[];
count: number;
first: string;
next: string;
diff --git a/src/app/shared/components/filter-chips/filter-chips.component.html b/src/app/shared/components/filter-chips/filter-chips.component.html
new file mode 100644
index 00000000..5533f3df
--- /dev/null
+++ b/src/app/shared/components/filter-chips/filter-chips.component.html
@@ -0,0 +1,12 @@
+@if (chips().length > 0) {
+
+ @for (chip of chips(); track chip.key + chip.value) {
+
+ }
+
+}
diff --git a/src/app/shared/components/filter-chips/filter-chips.component.spec.ts b/src/app/shared/components/filter-chips/filter-chips.component.spec.ts
new file mode 100644
index 00000000..6869018f
--- /dev/null
+++ b/src/app/shared/components/filter-chips/filter-chips.component.spec.ts
@@ -0,0 +1,243 @@
+import { ComponentRef } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+
+import { FilterChipsComponent } from './filter-chips.component';
+
+describe('FilterChipsComponent', () => {
+ let component: FilterChipsComponent;
+ let fixture: ComponentFixture;
+ let componentRef: ComponentRef;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [FilterChipsComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(FilterChipsComponent);
+ component = fixture.componentInstance;
+ componentRef = fixture.componentRef;
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('Component Initialization', () => {
+ it('should have default input values', () => {
+ expect(component.selectedValues()).toEqual({});
+ expect(component.filterLabels()).toEqual({});
+ expect(component.filterOptions()).toEqual({});
+ });
+
+ it('should not display anything when no chips are present', () => {
+ fixture.detectChanges();
+ const chipContainer = fixture.debugElement.query(By.css('.flex.flex-wrap'));
+ expect(chipContainer).toBeFalsy();
+ });
+ });
+
+ describe('Chips Display', () => {
+ beforeEach(() => {
+ // Set up test data
+ componentRef.setInput('selectedValues', {
+ subject: 'psychology',
+ resourceType: 'project',
+ });
+ componentRef.setInput('filterLabels', {
+ subject: 'Subject',
+ resourceType: 'Resource Type',
+ });
+ componentRef.setInput('filterOptions', {
+ subject: [{ id: 'psychology', value: 'psychology', label: 'Psychology' }],
+ resourceType: [{ id: 'project', value: 'project', label: 'Project' }],
+ });
+ fixture.detectChanges();
+ });
+
+ it('should display chips for selected values', () => {
+ const chips = fixture.debugElement.queryAll(By.css('.filter-chip'));
+ expect(chips.length).toBe(2);
+ });
+
+ it('should display correct chip labels and values', () => {
+ const chips = fixture.debugElement.queryAll(By.css('.chip-label'));
+ const chipTexts = chips.map((chip) => chip.nativeElement.textContent.trim());
+
+ expect(chipTexts).toContain('Subject: Psychology');
+ expect(chipTexts).toContain('Resource Type: Project');
+ });
+
+ it('should display remove button for each chip', () => {
+ const removeButtons = fixture.debugElement.queryAll(By.css('.chip-remove'));
+ expect(removeButtons.length).toBe(2);
+ });
+
+ it('should display clear all button when multiple chips are present', () => {
+ const clearAllButton = fixture.debugElement.query(By.css('.clear-all-btn'));
+ expect(clearAllButton).toBeTruthy();
+ expect(clearAllButton.nativeElement.textContent.trim()).toBe('Clear all');
+ });
+
+ it('should have proper aria-label for remove buttons', () => {
+ const removeButtons = fixture.debugElement.queryAll(By.css('.chip-remove'));
+ const ariaLabels = removeButtons.map((btn) => btn.nativeElement.getAttribute('aria-label'));
+
+ expect(ariaLabels).toContain('Remove Subject filter');
+ expect(ariaLabels).toContain('Remove Resource Type filter');
+ });
+ });
+
+ describe('Single Chip Behavior', () => {
+ beforeEach(() => {
+ componentRef.setInput('selectedValues', {
+ subject: 'psychology',
+ });
+ componentRef.setInput('filterLabels', {
+ subject: 'Subject',
+ });
+ componentRef.setInput('filterOptions', {
+ subject: [{ id: 'psychology', value: 'psychology', label: 'Psychology' }],
+ });
+ fixture.detectChanges();
+ });
+
+ it('should not display clear all button for single chip', () => {
+ const clearAllButton = fixture.debugElement.query(By.css('.clear-all-btn'));
+ expect(clearAllButton).toBeFalsy();
+ });
+
+ it('should still display remove button for single chip', () => {
+ const removeButtons = fixture.debugElement.queryAll(By.css('.chip-remove'));
+ expect(removeButtons.length).toBe(1);
+ });
+ });
+
+ describe('Event Emissions', () => {
+ beforeEach(() => {
+ componentRef.setInput('selectedValues', {
+ subject: 'psychology',
+ resourceType: 'project',
+ });
+ componentRef.setInput('filterLabels', {
+ subject: 'Subject',
+ resourceType: 'Resource Type',
+ });
+ fixture.detectChanges();
+ });
+
+ it('should emit filterRemoved when remove button is clicked', () => {
+ spyOn(component.filterRemoved, 'emit');
+
+ const removeButtons = fixture.debugElement.queryAll(By.css('.chip-remove'));
+ removeButtons[0].nativeElement.click();
+
+ expect(component.filterRemoved.emit).toHaveBeenCalledWith('subject');
+ });
+
+ it('should emit allFiltersCleared when clear all button is clicked', () => {
+ spyOn(component.allFiltersCleared, 'emit');
+
+ const clearAllButton = fixture.debugElement.query(By.css('.clear-all-btn'));
+ clearAllButton.nativeElement.click();
+
+ expect(component.allFiltersCleared.emit).toHaveBeenCalled();
+ });
+ });
+
+ describe('Chips Computed Property', () => {
+ it('should filter out null and empty values', () => {
+ componentRef.setInput('selectedValues', {
+ subject: 'psychology',
+ resourceType: null,
+ creator: '',
+ funder: 'nsf',
+ });
+ componentRef.setInput('filterLabels', {
+ subject: 'Subject',
+ funder: 'Funder',
+ });
+ fixture.detectChanges();
+
+ const chips = component.chips();
+ expect(chips.length).toBe(2);
+ expect(chips.map((c) => c.key)).toEqual(['subject', 'funder']);
+ });
+
+ it('should use raw value when no option label is found', () => {
+ componentRef.setInput('selectedValues', {
+ subject: 'unknown-subject',
+ });
+ componentRef.setInput('filterLabels', {
+ subject: 'Subject',
+ });
+ componentRef.setInput('filterOptions', {
+ subject: [{ id: 'psychology', value: 'psychology', label: 'Psychology' }],
+ });
+ fixture.detectChanges();
+
+ const chips = component.chips();
+ expect(chips[0].displayValue).toBe('unknown-subject');
+ });
+
+ it('should use filter key as label when no label is provided', () => {
+ componentRef.setInput('selectedValues', {
+ subject: 'psychology',
+ });
+ componentRef.setInput('filterLabels', {});
+ fixture.detectChanges();
+
+ const chips = component.chips();
+ expect(chips[0].label).toBe('subject');
+ });
+ });
+
+ describe('Component Methods', () => {
+ it('should call filterRemoved.emit with correct parameter in removeFilter', () => {
+ spyOn(component.filterRemoved, 'emit');
+
+ component.removeFilter('testKey');
+
+ expect(component.filterRemoved.emit).toHaveBeenCalledWith('testKey');
+ });
+
+ it('should call allFiltersCleared.emit in clearAllFilters', () => {
+ spyOn(component.allFiltersCleared, 'emit');
+
+ component.clearAllFilters();
+
+ expect(component.allFiltersCleared.emit).toHaveBeenCalled();
+ });
+ });
+
+ describe('Edge Cases', () => {
+ it('should handle empty filter options gracefully', () => {
+ componentRef.setInput('selectedValues', {
+ subject: 'psychology',
+ });
+ componentRef.setInput('filterLabels', {
+ subject: 'Subject',
+ });
+ componentRef.setInput('filterOptions', {});
+ fixture.detectChanges();
+
+ const chips = component.chips();
+ expect(chips[0].displayValue).toBe('psychology');
+ });
+
+ it('should handle undefined filter options gracefully', () => {
+ componentRef.setInput('selectedValues', {
+ subject: 'psychology',
+ });
+ componentRef.setInput('filterLabels', {
+ subject: 'Subject',
+ });
+ // filterOptions not set (undefined)
+ fixture.detectChanges();
+
+ expect(() => fixture.detectChanges()).not.toThrow();
+ const chips = component.chips();
+ expect(chips[0].displayValue).toBe('psychology');
+ });
+ });
+});
diff --git a/src/app/shared/components/filter-chips/filter-chips.component.ts b/src/app/shared/components/filter-chips/filter-chips.component.ts
new file mode 100644
index 00000000..8cd87f2e
--- /dev/null
+++ b/src/app/shared/components/filter-chips/filter-chips.component.ts
@@ -0,0 +1,49 @@
+import { Chip } from 'primeng/chip';
+
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
+
+@Component({
+ selector: 'osf-filter-chips',
+ imports: [CommonModule, Chip],
+ templateUrl: './filter-chips.component.html',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class FilterChipsComponent {
+ selectedValues = input>({});
+ filterLabels = input>({});
+ filterOptions = input>({});
+
+ filterRemoved = output();
+ allFiltersCleared = output();
+
+ readonly chips = computed(() => {
+ const values = this.selectedValues();
+ const labels = this.filterLabels();
+ const options = this.filterOptions();
+
+ return Object.entries(values)
+ .filter(([key, value]) => value !== null && value !== '')
+ .map(([key, value]) => {
+ const filterLabel = labels[key] || key;
+ const filterOptionsList = options[key] || [];
+ const option = filterOptionsList.find((opt) => opt.value === value || opt.id === value);
+ const displayValue = option?.label || value || '';
+
+ return {
+ key,
+ value: value!,
+ label: filterLabel,
+ displayValue,
+ };
+ });
+ });
+
+ removeFilter(filterKey: string): void {
+ this.filterRemoved.emit(filterKey);
+ }
+
+ clearAllFilters(): void {
+ this.allFiltersCleared.emit();
+ }
+}
diff --git a/src/app/shared/components/generic-filter/generic-filter.component.html b/src/app/shared/components/generic-filter/generic-filter.component.html
new file mode 100644
index 00000000..960c8579
--- /dev/null
+++ b/src/app/shared/components/generic-filter/generic-filter.component.html
@@ -0,0 +1,22 @@
+
+ @if (isLoading()) {
+
+
+
+ } @else {
+
+ }
+
diff --git a/src/app/shared/components/generic-filter/generic-filter.component.spec.ts b/src/app/shared/components/generic-filter/generic-filter.component.spec.ts
new file mode 100644
index 00000000..14c1deed
--- /dev/null
+++ b/src/app/shared/components/generic-filter/generic-filter.component.spec.ts
@@ -0,0 +1,394 @@
+import { Select, SelectChangeEvent } from 'primeng/select';
+
+import { ComponentRef } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormsModule } from '@angular/forms';
+import { By } from '@angular/platform-browser';
+
+import { LoadingSpinnerComponent } from '@shared/components';
+import { SelectOption } from '@shared/models';
+
+import { GenericFilterComponent } from './generic-filter.component';
+
+describe('GenericFilterComponent', () => {
+ let component: GenericFilterComponent;
+ let fixture: ComponentFixture;
+ let componentRef: ComponentRef;
+
+ const mockOptions: SelectOption[] = [
+ { label: 'Option 1', value: 'value1' },
+ { label: 'Option 2', value: 'value2' },
+ { label: 'Option 3', value: 'value3' },
+ ];
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [GenericFilterComponent, FormsModule, Select, LoadingSpinnerComponent],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(GenericFilterComponent);
+ component = fixture.componentInstance;
+ componentRef = fixture.componentRef;
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('Input Properties', () => {
+ it('should initialize with default values', () => {
+ expect(component.options()).toEqual([]);
+ expect(component.isLoading()).toBe(false);
+ expect(component.selectedValue()).toBeNull();
+ expect(component.placeholder()).toBe('');
+ expect(component.editable()).toBe(false);
+ expect(component.filterType()).toBe('');
+ });
+
+ it('should accept options input', () => {
+ componentRef.setInput('options', mockOptions);
+ fixture.detectChanges();
+
+ expect(component.options()).toEqual(mockOptions);
+ });
+
+ it('should accept isLoading input', () => {
+ componentRef.setInput('isLoading', true);
+ fixture.detectChanges();
+
+ expect(component.isLoading()).toBe(true);
+ });
+
+ it('should accept selectedValue input', () => {
+ componentRef.setInput('selectedValue', 'value1');
+ fixture.detectChanges();
+
+ expect(component.selectedValue()).toBe('value1');
+ });
+
+ it('should accept placeholder input', () => {
+ componentRef.setInput('placeholder', 'Select an option');
+ fixture.detectChanges();
+
+ expect(component.placeholder()).toBe('Select an option');
+ });
+
+ it('should accept editable input', () => {
+ componentRef.setInput('editable', true);
+ fixture.detectChanges();
+
+ expect(component.editable()).toBe(true);
+ });
+
+ it('should accept filterType input', () => {
+ componentRef.setInput('filterType', 'subject');
+ fixture.detectChanges();
+
+ expect(component.filterType()).toBe('subject');
+ });
+ });
+
+ describe('Computed Properties', () => {
+ it('should return empty array when no options provided', () => {
+ expect(component.filterOptions()).toEqual([]);
+ });
+
+ it('should filter out options without labels', () => {
+ const optionsWithEmpty: SelectOption[] = [
+ { label: 'Valid Option', value: 'valid' },
+ { label: '', value: 'empty' },
+ { label: 'Another Valid', value: 'valid2' },
+ ];
+
+ componentRef.setInput('options', optionsWithEmpty);
+ fixture.detectChanges();
+
+ const filteredOptions = component.filterOptions();
+ expect(filteredOptions).toHaveLength(2);
+ expect(filteredOptions[0].label).toBe('Valid Option');
+ expect(filteredOptions[1].label).toBe('Another Valid');
+ });
+
+ it('should map options correctly', () => {
+ componentRef.setInput('options', mockOptions);
+ fixture.detectChanges();
+
+ const filteredOptions = component.filterOptions();
+ expect(filteredOptions).toHaveLength(3);
+ expect(filteredOptions[0]).toEqual({ label: 'Option 1', value: 'value1' });
+ expect(filteredOptions[1]).toEqual({ label: 'Option 2', value: 'value2' });
+ });
+ });
+
+ describe('Current Selected Option Signal', () => {
+ beforeEach(() => {
+ componentRef.setInput('options', mockOptions);
+ fixture.detectChanges();
+ });
+
+ it('should set currentSelectedOption to null when no value selected', () => {
+ componentRef.setInput('selectedValue', null);
+ fixture.detectChanges();
+
+ expect(component.currentSelectedOption()).toBeNull();
+ });
+
+ it('should set currentSelectedOption when selectedValue matches an option', () => {
+ componentRef.setInput('selectedValue', 'value2');
+ fixture.detectChanges();
+
+ const currentOption = component.currentSelectedOption();
+ expect(currentOption).toEqual({ label: 'Option 2', value: 'value2' });
+ });
+
+ it('should set currentSelectedOption to null when selectedValue does not match any option', () => {
+ componentRef.setInput('selectedValue', 'nonexistent');
+ fixture.detectChanges();
+
+ expect(component.currentSelectedOption()).toBeNull();
+ });
+
+ it('should update currentSelectedOption when selectedValue changes', () => {
+ componentRef.setInput('selectedValue', 'value1');
+ fixture.detectChanges();
+
+ expect(component.currentSelectedOption()).toEqual({ label: 'Option 1', value: 'value1' });
+
+ componentRef.setInput('selectedValue', 'value3');
+ fixture.detectChanges();
+
+ expect(component.currentSelectedOption()).toEqual({ label: 'Option 3', value: 'value3' });
+ });
+ });
+
+ describe('Template Rendering', () => {
+ it('should show loading spinner when isLoading is true', () => {
+ componentRef.setInput('isLoading', true);
+ fixture.detectChanges();
+
+ const loadingSpinner = fixture.debugElement.query(By.directive(LoadingSpinnerComponent));
+ const selectElement = fixture.debugElement.query(By.directive(Select));
+
+ expect(loadingSpinner).toBeTruthy();
+ expect(selectElement).toBeFalsy();
+ });
+
+ it('should show select component when isLoading is false', () => {
+ componentRef.setInput('isLoading', false);
+ fixture.detectChanges();
+
+ const loadingSpinner = fixture.debugElement.query(By.directive(LoadingSpinnerComponent));
+ const selectElement = fixture.debugElement.query(By.directive(Select));
+
+ expect(loadingSpinner).toBeFalsy();
+ expect(selectElement).toBeTruthy();
+ });
+
+ it('should pass correct properties to p-select', () => {
+ componentRef.setInput('options', mockOptions);
+ componentRef.setInput('selectedValue', 'value1');
+ componentRef.setInput('placeholder', 'Choose option');
+ componentRef.setInput('editable', true);
+ componentRef.setInput('filterType', 'subject');
+ fixture.detectChanges();
+
+ const selectElement = fixture.debugElement.query(By.directive(Select));
+ const selectComponent = selectElement.componentInstance;
+
+ expect(selectComponent.id).toBe('subject');
+ expect(selectComponent.options).toEqual(component.filterOptions());
+ expect(selectComponent.optionLabel).toBe('label');
+ expect(selectComponent.optionValue).toBe('value');
+ expect(selectComponent.ngModel).toBe('value1');
+ expect(selectComponent.editable).toBe(true);
+ expect(selectComponent.styleClass).toBe('w-full');
+ expect(selectComponent.appendTo).toBe('body');
+ expect(selectComponent.filter).toBe(true);
+ expect(selectComponent.showClear).toBe(true);
+ });
+
+ it('should show selected option label as placeholder when option is selected', () => {
+ componentRef.setInput('options', mockOptions);
+ componentRef.setInput('selectedValue', 'value2');
+ componentRef.setInput('placeholder', 'Default placeholder');
+ fixture.detectChanges();
+
+ const selectElement = fixture.debugElement.query(By.directive(Select));
+ const selectComponent = selectElement.componentInstance;
+
+ expect(selectComponent.placeholder).toBe('Option 2');
+ });
+
+ it('should show default placeholder when no option is selected', () => {
+ componentRef.setInput('options', mockOptions);
+ componentRef.setInput('selectedValue', null);
+ componentRef.setInput('placeholder', 'Default placeholder');
+ fixture.detectChanges();
+
+ const selectElement = fixture.debugElement.query(By.directive(Select));
+ const selectComponent = selectElement.componentInstance;
+
+ expect(selectComponent.placeholder).toBe('Default placeholder');
+ });
+
+ it('should not show clear button when no value is selected', () => {
+ componentRef.setInput('selectedValue', null);
+ fixture.detectChanges();
+
+ const selectElement = fixture.debugElement.query(By.directive(Select));
+ const selectComponent = selectElement.componentInstance;
+
+ expect(selectComponent.showClear).toBe(false);
+ });
+ });
+
+ describe('Event Handling', () => {
+ beforeEach(() => {
+ componentRef.setInput('options', mockOptions);
+ fixture.detectChanges();
+ });
+
+ it('should emit valueChanged when onValueChange is called with a value', () => {
+ spyOn(component.valueChanged, 'emit');
+
+ const mockEvent: SelectChangeEvent = {
+ originalEvent: new Event('change'),
+ value: 'value2',
+ };
+
+ component.onValueChange(mockEvent);
+
+ expect(component.valueChanged.emit).toHaveBeenCalledWith('value2');
+ });
+
+ it('should emit null when onValueChange is called with null value', () => {
+ spyOn(component.valueChanged, 'emit');
+
+ const mockEvent: SelectChangeEvent = {
+ originalEvent: new Event('change'),
+ value: null,
+ };
+
+ component.onValueChange(mockEvent);
+
+ expect(component.valueChanged.emit).toHaveBeenCalledWith(null);
+ });
+
+ it('should update currentSelectedOption when onValueChange is called', () => {
+ const mockEvent: SelectChangeEvent = {
+ originalEvent: new Event('change'),
+ value: 'value3',
+ };
+
+ component.onValueChange(mockEvent);
+
+ expect(component.currentSelectedOption()).toEqual({ label: 'Option 3', value: 'value3' });
+ });
+
+ it('should set currentSelectedOption to null when clearing selection', () => {
+ // First select an option
+ componentRef.setInput('selectedValue', 'value1');
+ fixture.detectChanges();
+
+ expect(component.currentSelectedOption()).toEqual({ label: 'Option 1', value: 'value1' });
+
+ // Then clear it
+ const mockEvent: SelectChangeEvent = {
+ originalEvent: new Event('change'),
+ value: null,
+ };
+
+ component.onValueChange(mockEvent);
+
+ expect(component.currentSelectedOption()).toBeNull();
+ });
+
+ it('should trigger onChange event in template', () => {
+ spyOn(component, 'onValueChange');
+
+ const selectElement = fixture.debugElement.query(By.directive(Select));
+ const selectComponent = selectElement.componentInstance;
+
+ const mockEvent: SelectChangeEvent = {
+ originalEvent: new Event('change'),
+ value: 'value1',
+ };
+
+ selectComponent.onChange.emit(mockEvent);
+
+ expect(component.onValueChange).toHaveBeenCalledWith(mockEvent);
+ });
+ });
+
+ describe('Edge Cases', () => {
+ it('should handle empty options array', () => {
+ componentRef.setInput('options', []);
+ fixture.detectChanges();
+
+ expect(component.filterOptions()).toEqual([]);
+ expect(component.currentSelectedOption()).toBeNull();
+ });
+
+ it('should handle options with null or undefined labels', () => {
+ const problematicOptions = [
+ { label: 'Valid', value: 'valid' },
+ { label: null, value: 'null-label' },
+ { label: undefined, value: 'undefined-label' },
+ ];
+
+ componentRef.setInput('options', problematicOptions);
+ fixture.detectChanges();
+
+ const filteredOptions = component.filterOptions();
+ expect(filteredOptions).toHaveLength(1);
+ expect(filteredOptions[0].label).toBe('Valid');
+ });
+
+ it('should handle selectedValue that becomes invalid when options change', () => {
+ componentRef.setInput('options', mockOptions);
+ componentRef.setInput('selectedValue', 'value2');
+ fixture.detectChanges();
+
+ expect(component.currentSelectedOption()).toEqual({ label: 'Option 2', value: 'value2' });
+
+ // Change options to not include the selected value
+ const newOptions: SelectOption[] = [{ label: 'New Option', value: 'new-value' }];
+ componentRef.setInput('options', newOptions);
+ fixture.detectChanges();
+
+ expect(component.currentSelectedOption()).toBeNull();
+ });
+ });
+
+ describe('Accessibility', () => {
+ it('should set proper id attribute for the select element', () => {
+ componentRef.setInput('filterType', 'subject-filter');
+ fixture.detectChanges();
+
+ const selectElement = fixture.debugElement.query(By.directive(Select));
+ const selectComponent = selectElement.componentInstance;
+
+ expect(selectComponent.id).toBe('subject-filter');
+ });
+
+ it('should enable filter when editable is true', () => {
+ componentRef.setInput('editable', true);
+ fixture.detectChanges();
+
+ const selectElement = fixture.debugElement.query(By.directive(Select));
+ const selectComponent = selectElement.componentInstance;
+
+ expect(selectComponent.filter).toBe(true);
+ });
+
+ it('should disable filter when editable is false', () => {
+ componentRef.setInput('editable', false);
+ fixture.detectChanges();
+
+ const selectElement = fixture.debugElement.query(By.directive(Select));
+ const selectComponent = selectElement.componentInstance;
+
+ expect(selectComponent.filter).toBe(false);
+ });
+ });
+});
diff --git a/src/app/shared/components/generic-filter/generic-filter.component.ts b/src/app/shared/components/generic-filter/generic-filter.component.ts
new file mode 100644
index 00000000..bea8cbac
--- /dev/null
+++ b/src/app/shared/components/generic-filter/generic-filter.component.ts
@@ -0,0 +1,60 @@
+import { Select, SelectChangeEvent } from 'primeng/select';
+
+import { ChangeDetectionStrategy, Component, computed, effect, input, output, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+
+import { LoadingSpinnerComponent } from '@shared/components';
+import { SelectOption } from '@shared/models';
+
+@Component({
+ selector: 'osf-generic-filter',
+ imports: [Select, FormsModule, LoadingSpinnerComponent],
+ templateUrl: './generic-filter.component.html',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class GenericFilterComponent {
+ options = input([]);
+ isLoading = input(false);
+ selectedValue = input(null);
+ placeholder = input('');
+ filterType = input('');
+
+ valueChanged = output();
+
+ currentSelectedOption = signal(null);
+
+ filterOptions = computed(() => {
+ const parentOptions = this.options();
+ if (parentOptions.length > 0) {
+ return parentOptions
+ .filter((option) => option?.label)
+ .map((option) => ({
+ label: option.label || '',
+ value: option.value || '',
+ }));
+ }
+ return [];
+ });
+
+ constructor() {
+ effect(() => {
+ const selectedValue = this.selectedValue();
+ const options = this.filterOptions();
+
+ if (!selectedValue) {
+ this.currentSelectedOption.set(null);
+ } else {
+ const option = options.find((opt) => opt.value === selectedValue);
+ this.currentSelectedOption.set(option || null);
+ }
+ });
+ }
+
+ onValueChange(event: SelectChangeEvent): void {
+ const options = this.filterOptions();
+ const selectedOption = event.value ? options.find((opt) => opt.value === event.value) : null;
+ this.currentSelectedOption.set(selectedOption || null);
+
+ this.valueChanged.emit(event.value || null);
+ }
+}
diff --git a/src/app/shared/components/index.ts b/src/app/shared/components/index.ts
index 0ef79a98..316973be 100644
--- a/src/app/shared/components/index.ts
+++ b/src/app/shared/components/index.ts
@@ -8,8 +8,10 @@ export { EmploymentHistoryComponent } from './employment-history/employment-hist
export { EmploymentHistoryDialogComponent } from './employment-history-dialog/employment-history-dialog.component';
export { FileMenuComponent } from './file-menu/file-menu.component';
export { FilesTreeComponent } from './files-tree/files-tree.component';
+export { FilterChipsComponent } from './filter-chips/filter-chips.component';
export { FormSelectComponent } from './form-select/form-select.component';
export { FullScreenLoaderComponent } from './full-screen-loader/full-screen-loader.component';
+export { GenericFilterComponent } from './generic-filter/generic-filter.component';
export { IconComponent } from './icon/icon.component';
export { InfoIconComponent } from './info-icon/info-icon.component';
export { LineChartComponent } from './line-chart/line-chart.component';
@@ -19,8 +21,10 @@ export { MyProjectsTableComponent } from './my-projects-table/my-projects-table.
export { PasswordInputHintComponent } from './password-input-hint/password-input-hint.component';
export { PieChartComponent } from './pie-chart/pie-chart.component';
export { ResourceCardComponent } from './resource-card/resource-card.component';
+export { ReusableFilterComponent } from './reusable-filter/reusable-filter.component';
export { SearchHelpTutorialComponent } from './search-help-tutorial/search-help-tutorial.component';
export { SearchInputComponent } from './search-input/search-input.component';
+export { SearchResultsContainerComponent } from './search-results-container/search-results-container.component';
export { SelectComponent } from './select/select.component';
export { StepperComponent } from './stepper/stepper.component';
export { SubHeaderComponent } from './sub-header/sub-header.component';
diff --git a/src/app/shared/components/reusable-filter/reusable-filter.component.html b/src/app/shared/components/reusable-filter/reusable-filter.component.html
new file mode 100644
index 00000000..98cc026f
--- /dev/null
+++ b/src/app/shared/components/reusable-filter/reusable-filter.component.html
@@ -0,0 +1,50 @@
+@if (isLoading()) {
+
+
+
+} @else if (hasVisibleFilters()) {
+
+
+ @for (filter of visibleFilters(); track filter.key) {
+
+ {{ getFilterLabel(filter) }}
+
+ @if (getFilterDescription(filter)) {
+ {{ getFilterDescription(filter) }}
+ }
+
+ @if (getFilterHelpLink(filter) && getFilterHelpLinkText(filter)) {
+
+
+ {{ getFilterHelpLinkText(filter) }}
+
+
+ }
+
+ @if (hasFilterContent(filter)) {
+
+ } @else {
+ {{ 'collections.filters.noOptionsAvailable' | translate }}
+ }
+
+
+ }
+
+
+} @else if (showEmptyState()) {
+
+
{{ 'collections.filters.noFiltersAvailable' | translate }}
+
+}
diff --git a/src/app/shared/components/reusable-filter/reusable-filter.component.spec.ts b/src/app/shared/components/reusable-filter/reusable-filter.component.spec.ts
new file mode 100644
index 00000000..1347e7bb
--- /dev/null
+++ b/src/app/shared/components/reusable-filter/reusable-filter.component.spec.ts
@@ -0,0 +1,469 @@
+import { ComponentRef } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+
+import { FILTER_PLACEHOLDERS } from '@shared/constants/filter-placeholders';
+import { DiscoverableFilter } from '@shared/models';
+
+import { ReusableFilterComponent } from './reusable-filter.component';
+
+describe('ReusableFilterComponent', () => {
+ let component: ReusableFilterComponent;
+ let fixture: ComponentFixture;
+ let componentRef: ComponentRef;
+
+ const mockFilters: DiscoverableFilter[] = [
+ {
+ key: 'subject',
+ label: 'Subject',
+ type: 'select',
+ operator: 'eq',
+ description: 'Filter by subject area',
+ helpLink: 'https://help.example.com/subjects',
+ helpLinkText: 'Learn about subjects',
+ resultCount: 150,
+ hasOptions: true,
+ options: [
+ { label: 'Psychology', value: 'psychology' },
+ { label: 'Biology', value: 'biology' },
+ ],
+ },
+ {
+ key: 'resourceType',
+ label: 'Resource Type',
+ type: 'select',
+ operator: 'eq',
+ options: [
+ { label: 'Project', value: 'project' },
+ { label: 'Registration', value: 'registration' },
+ ],
+ },
+ {
+ key: 'creator',
+ label: 'Creator',
+ type: 'select',
+ operator: 'eq',
+ hasOptions: true,
+ },
+ {
+ key: 'accessService',
+ label: 'Access Service',
+ type: 'select',
+ operator: 'eq',
+ // No options - should not be visible
+ },
+ ];
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [ReusableFilterComponent, NoopAnimationsModule],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(ReusableFilterComponent);
+ component = fixture.componentInstance;
+ componentRef = fixture.componentRef;
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('Component Initialization', () => {
+ it('should have default input values', () => {
+ expect(component.filters()).toEqual([]);
+ expect(component.selectedValues()).toEqual({});
+ expect(component.isLoading()).toBe(false);
+ expect(component.showEmptyState()).toBe(true);
+ });
+
+ it('should have access to FILTER_PLACEHOLDERS constant', () => {
+ expect(component.FILTER_PLACEHOLDERS).toBe(FILTER_PLACEHOLDERS);
+ });
+
+ it('should initialize with empty expandedFilters signal', () => {
+ expect(component['expandedFilters']()).toEqual(new Set());
+ });
+ });
+
+ describe('Loading State', () => {
+ beforeEach(() => {
+ componentRef.setInput('isLoading', true);
+ fixture.detectChanges();
+ });
+
+ it('should display loading state when isLoading is true', () => {
+ const loadingElement = fixture.debugElement.query(By.css('.text-center.text-gray-500 p'));
+ expect(loadingElement).toBeTruthy();
+ expect(loadingElement.nativeElement.textContent.trim()).toBe('Loading filters...');
+ });
+
+ it('should not display filters or empty state when loading', () => {
+ const accordion = fixture.debugElement.query(By.css('p-accordion'));
+ const emptyState = fixture.debugElement.query(By.css('.text-center.text-gray-500.py-4'));
+
+ expect(accordion).toBeFalsy();
+ expect(emptyState).toBeFalsy();
+ });
+ });
+
+ describe('Empty State', () => {
+ beforeEach(() => {
+ componentRef.setInput('filters', []);
+ componentRef.setInput('showEmptyState', true);
+ fixture.detectChanges();
+ });
+
+ it('should display empty state when no filters and showEmptyState is true', () => {
+ const emptyState = fixture.debugElement.query(By.css('.text-center.text-gray-500.py-4 p'));
+ expect(emptyState).toBeTruthy();
+ expect(emptyState.nativeElement.textContent.trim()).toBe('No filters available');
+ });
+
+ it('should not display empty state when showEmptyState is false', () => {
+ componentRef.setInput('showEmptyState', false);
+ fixture.detectChanges();
+
+ const emptyState = fixture.debugElement.query(By.css('.text-center.text-gray-500.py-4'));
+ expect(emptyState).toBeFalsy();
+ });
+ });
+
+ describe('Filters Display', () => {
+ beforeEach(() => {
+ componentRef.setInput('filters', mockFilters);
+ componentRef.setInput('selectedValues', {
+ subject: 'psychology',
+ creator: 'John Doe',
+ });
+ fixture.detectChanges();
+ });
+
+ it('should display accordion when filters are visible', () => {
+ const accordion = fixture.debugElement.query(By.css('p-accordion'));
+ expect(accordion).toBeTruthy();
+ });
+
+ it('should display visible filters in accordion panels', () => {
+ const panels = fixture.debugElement.queryAll(By.css('p-accordion-panel'));
+ // Should show subject, resourceType, and creator (accessService has no options)
+ expect(panels.length).toBe(3);
+ });
+
+ it('should display correct filter labels', () => {
+ const headers = fixture.debugElement.queryAll(By.css('p-accordion-header'));
+ const headerTexts = headers.map((h) => h.nativeElement.textContent.trim());
+
+ expect(headerTexts).toContain('Subject');
+ expect(headerTexts).toContain('Resource Type');
+ expect(headerTexts).toContain('Creator');
+ });
+ });
+
+ describe('shouldShowFilter method', () => {
+ it('should return false for null or undefined filter', () => {
+ expect(component.shouldShowFilter(null as unknown as DiscoverableFilter)).toBe(false);
+ expect(component.shouldShowFilter(undefined as unknown as DiscoverableFilter)).toBe(false);
+ });
+
+ it('should return false for filter without key', () => {
+ const filter = { label: 'Test' } as DiscoverableFilter;
+ expect(component.shouldShowFilter(filter)).toBe(false);
+ });
+
+ it('should return true for resourceType/accessService only if they have options', () => {
+ const filterWithOptions = {
+ key: 'resourceType',
+ options: [{ label: 'Test', value: 'test' }],
+ } as DiscoverableFilter;
+ const filterWithoutOptions = { key: 'resourceType' } as DiscoverableFilter;
+
+ expect(component.shouldShowFilter(filterWithOptions)).toBe(true);
+ expect(component.shouldShowFilter(filterWithoutOptions)).toBe(false);
+ });
+
+ it('should return true for filters with result count', () => {
+ const filter = { key: 'subject', resultCount: 10 } as DiscoverableFilter;
+ expect(component.shouldShowFilter(filter)).toBe(true);
+ });
+
+ it('should return true for filters with options', () => {
+ const filter = { key: 'subject', options: [{ label: 'Test', value: 'test' }] } as DiscoverableFilter;
+ expect(component.shouldShowFilter(filter)).toBe(true);
+ });
+
+ it('should return true for filters with hasOptions flag', () => {
+ const filter = { key: 'subject', hasOptions: true } as DiscoverableFilter;
+ expect(component.shouldShowFilter(filter)).toBe(true);
+ });
+
+ it('should return true for filters with selected values', () => {
+ const filter: DiscoverableFilter = {
+ key: 'subject',
+ label: 'Subject',
+ type: 'select',
+ operator: 'eq',
+ selectedValues: [{ label: 'Test', value: 'test' }],
+ };
+ expect(component.shouldShowFilter(filter)).toBe(true);
+ });
+ });
+
+ describe('Computed Properties', () => {
+ it('should compute hasFilters correctly', () => {
+ componentRef.setInput('filters', []);
+ expect(component.hasFilters()).toBe(false);
+
+ componentRef.setInput('filters', mockFilters);
+ expect(component.hasFilters()).toBe(true);
+ });
+
+ it('should compute visibleFilters correctly', () => {
+ componentRef.setInput('filters', mockFilters);
+ const visible = component.visibleFilters();
+
+ // Should exclude accessService (no options)
+ expect(visible.length).toBe(3);
+ expect(visible.map((f) => f.key)).toEqual(['subject', 'resourceType', 'creator']);
+ });
+
+ it('should compute hasVisibleFilters correctly', () => {
+ componentRef.setInput('filters', []);
+ expect(component.hasVisibleFilters()).toBe(false);
+
+ componentRef.setInput('filters', mockFilters);
+ expect(component.hasVisibleFilters()).toBe(true);
+ });
+ });
+
+ describe('Event Handling', () => {
+ beforeEach(() => {
+ componentRef.setInput('filters', mockFilters);
+ fixture.detectChanges();
+ });
+
+ it('should emit loadFilterOptions when accordion is toggled and filter needs options', () => {
+ spyOn(component.loadFilterOptions, 'emit');
+
+ // Mock a filter that has hasOptions but no options loaded
+ const filterNeedingOptions: DiscoverableFilter = {
+ key: 'creator',
+ label: 'Creator',
+ type: 'select',
+ operator: 'eq',
+ hasOptions: true,
+ };
+ componentRef.setInput('filters', [filterNeedingOptions]);
+ fixture.detectChanges();
+
+ component.onAccordionToggle('creator');
+
+ expect(component.loadFilterOptions.emit).toHaveBeenCalledWith({
+ filterType: 'creator',
+ filter: filterNeedingOptions,
+ });
+ });
+
+ it('should not emit loadFilterOptions when filter already has options', () => {
+ spyOn(component.loadFilterOptions, 'emit');
+
+ component.onAccordionToggle('subject');
+
+ expect(component.loadFilterOptions.emit).not.toHaveBeenCalled();
+ });
+
+ it('should emit filterValueChanged when filter value changes', () => {
+ spyOn(component.filterValueChanged, 'emit');
+
+ component.onFilterChanged('subject', 'biology');
+
+ expect(component.filterValueChanged.emit).toHaveBeenCalledWith({
+ filterType: 'subject',
+ value: 'biology',
+ });
+ });
+
+ it('should handle array filterKey in onAccordionToggle', () => {
+ spyOn(component.loadFilterOptions, 'emit');
+
+ component.onAccordionToggle(['subject', 'other']);
+
+ // Should use first element of array
+ expect(component['expandedFilters']().has('subject')).toBe(true);
+ });
+
+ it('should handle empty filterKey in onAccordionToggle', () => {
+ const initialExpanded = new Set(component['expandedFilters']());
+
+ component.onAccordionToggle('');
+ component.onAccordionToggle(null as unknown as string);
+
+ expect(component['expandedFilters']()).toEqual(initialExpanded);
+ });
+ });
+
+ describe('Helper Methods', () => {
+ const testFilter: DiscoverableFilter = {
+ key: 'subject',
+ label: 'Subject',
+ type: 'select',
+ operator: 'eq',
+ description: 'Test description',
+ helpLink: 'https://help.test.com',
+ helpLinkText: 'Custom help text',
+ resultCount: 42,
+ options: [{ label: 'Test Option', value: 'test' }],
+ isLoading: true,
+ };
+
+ it('should return correct filter options', () => {
+ expect(component.getFilterOptions(testFilter)).toEqual(testFilter.options || []);
+ expect(component.getFilterOptions({} as DiscoverableFilter)).toEqual([]);
+ });
+
+ it('should return correct loading state', () => {
+ expect(component.isFilterLoading(testFilter)).toBe(true);
+ expect(component.isFilterLoading({} as DiscoverableFilter)).toBe(false);
+ });
+
+ it('should return correct selected value', () => {
+ componentRef.setInput('selectedValues', { subject: 'psychology' });
+
+ expect(component.getSelectedValue('subject')).toBe('psychology');
+ expect(component.getSelectedValue('nonexistent')).toBe(null);
+ });
+
+ it('should return correct filter placeholder', () => {
+ expect(component.getFilterPlaceholder('subject')).toBe('Select subject');
+ expect(component.getFilterPlaceholder('unknown')).toBe('Search...');
+ });
+
+ it('should return correct filter editability', () => {
+ expect(component.isFilterEditable('subject')).toBe(true);
+ expect(component.isFilterEditable('affiliation')).toBe(false);
+ expect(component.isFilterEditable('unknown')).toBe(true); // default
+ });
+
+ it('should return correct filter description', () => {
+ expect(component.getFilterDescription(testFilter)).toBe('Test description');
+ expect(component.getFilterDescription({} as DiscoverableFilter)).toBe(null);
+ });
+
+ it('should return correct filter help link', () => {
+ expect(component.getFilterHelpLink(testFilter)).toBe('https://help.test.com');
+ expect(component.getFilterHelpLink({} as DiscoverableFilter)).toBe(null);
+ });
+
+ it('should return correct filter help link text', () => {
+ expect(component.getFilterHelpLinkText(testFilter)).toBe('Custom help text');
+ expect(component.getFilterHelpLinkText({} as DiscoverableFilter)).toBe('Learn more');
+ });
+
+ it('should return correct filter label with fallbacks', () => {
+ expect(component.getFilterLabel(testFilter)).toBe('Subject');
+ expect(component.getFilterLabel({ key: 'test' } as DiscoverableFilter)).toBe('test');
+ expect(component.getFilterLabel({} as DiscoverableFilter)).toBe('Filter');
+ });
+
+ it('should determine filter content correctly', () => {
+ expect(component.hasFilterContent(testFilter)).toBe(true);
+
+ const emptyFilter = {} as DiscoverableFilter;
+ expect(component.hasFilterContent(emptyFilter)).toBe(false);
+
+ const filterWithOnlyHasOptions = { hasOptions: true } as DiscoverableFilter;
+ expect(component.hasFilterContent(filterWithOnlyHasOptions)).toBe(true);
+ });
+ });
+
+ describe('Expanded Filters State', () => {
+ beforeEach(() => {
+ componentRef.setInput('filters', mockFilters);
+ fixture.detectChanges();
+ });
+
+ it('should toggle expanded state correctly', () => {
+ expect(component['expandedFilters']().has('subject')).toBe(false);
+
+ component.onAccordionToggle('subject');
+ expect(component['expandedFilters']().has('subject')).toBe(true);
+
+ component.onAccordionToggle('subject');
+ expect(component['expandedFilters']().has('subject')).toBe(false);
+ });
+
+ it('should handle multiple expanded filters', () => {
+ component.onAccordionToggle('subject');
+ component.onAccordionToggle('creator');
+
+ expect(component['expandedFilters']().has('subject')).toBe(true);
+ expect(component['expandedFilters']().has('creator')).toBe(true);
+ });
+ });
+
+ describe('Integration Tests', () => {
+ beforeEach(() => {
+ componentRef.setInput('filters', mockFilters);
+ componentRef.setInput('selectedValues', { subject: 'psychology' });
+ fixture.detectChanges();
+ });
+
+ it('should pass correct props to generic filter components', () => {
+ const genericFilters = fixture.debugElement.queryAll(By.css('osf-generic-filter'));
+ expect(genericFilters.length).toBeGreaterThan(0);
+
+ // Check if generic filter receives correct inputs
+ const subjectFilter = genericFilters.find((gf) => gf.componentInstance.filterType === 'subject');
+
+ if (subjectFilter) {
+ expect(subjectFilter.componentInstance.selectedValue).toBe('psychology');
+ expect(subjectFilter.componentInstance.placeholder).toBe('Select subject');
+ expect(subjectFilter.componentInstance.editable).toBe(true);
+ }
+ });
+
+ it('should handle filter value change events from generic filter', () => {
+ spyOn(component, 'onFilterChanged');
+
+ const genericFilter = fixture.debugElement.query(By.css('osf-generic-filter'));
+ if (genericFilter) {
+ genericFilter.componentInstance.valueChanged.emit('new-value');
+
+ expect(component.onFilterChanged).toHaveBeenCalled();
+ }
+ });
+ });
+
+ describe('Edge Cases', () => {
+ it('should handle malformed filter data gracefully', () => {
+ const malformedFilters = [
+ null,
+ undefined,
+ { key: null },
+ { key: '', label: '' },
+ { key: 'valid', options: null },
+ ] as unknown as DiscoverableFilter[];
+
+ expect(() => {
+ componentRef.setInput('filters', malformedFilters);
+ fixture.detectChanges();
+ }).not.toThrow();
+ });
+
+ it('should handle empty selected values', () => {
+ componentRef.setInput('selectedValues', {});
+ componentRef.setInput('filters', mockFilters);
+ fixture.detectChanges();
+
+ expect(component.getSelectedValue('subject')).toBe(null);
+ });
+
+ it('should handle filters without required properties', () => {
+ const minimalFilter = { key: 'minimal' } as DiscoverableFilter;
+
+ expect(component.getFilterLabel(minimalFilter)).toBe('minimal');
+ expect(component.getFilterDescription(minimalFilter)).toBe(null);
+ expect(component.hasFilterContent(minimalFilter)).toBe(false);
+ });
+ });
+});
diff --git a/src/app/shared/components/reusable-filter/reusable-filter.component.ts b/src/app/shared/components/reusable-filter/reusable-filter.component.ts
new file mode 100644
index 00000000..f2b16520
--- /dev/null
+++ b/src/app/shared/components/reusable-filter/reusable-filter.component.ts
@@ -0,0 +1,144 @@
+import { TranslatePipe } from '@ngx-translate/core';
+
+import { Accordion, AccordionContent, AccordionHeader, AccordionPanel } from 'primeng/accordion';
+import { AutoCompleteModule } from 'primeng/autocomplete';
+
+import { ChangeDetectionStrategy, Component, computed, input, output, signal } from '@angular/core';
+import { ReactiveFormsModule } from '@angular/forms';
+
+import { LoadingSpinnerComponent } from '@shared/components';
+import { FILTER_PLACEHOLDERS } from '@shared/constants/filter-placeholders';
+import { ReusableFilterType } from '@shared/enums';
+import { DiscoverableFilter, SelectOption } from '@shared/models';
+
+import { GenericFilterComponent } from '../generic-filter/generic-filter.component';
+
+@Component({
+ selector: 'osf-reusable-filters',
+ imports: [
+ Accordion,
+ AccordionContent,
+ AccordionHeader,
+ AccordionPanel,
+ AutoCompleteModule,
+ ReactiveFormsModule,
+ GenericFilterComponent,
+ TranslatePipe,
+ LoadingSpinnerComponent,
+ ],
+ templateUrl: './reusable-filter.component.html',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ReusableFilterComponent {
+ filters = input([]);
+ selectedValues = input>({});
+ isLoading = input(false);
+ showEmptyState = input(true);
+
+ loadFilterOptions = output<{ filterType: string; filter: DiscoverableFilter }>();
+ filterValueChanged = output<{ filterType: string; value: string | null }>();
+
+ private readonly expandedFilters = signal>(new Set());
+
+ readonly FILTER_PLACEHOLDERS = FILTER_PLACEHOLDERS;
+
+ readonly hasFilters = computed(() => {
+ const filterList = this.filters();
+ return filterList && filterList.length > 0;
+ });
+
+ readonly visibleFilters = computed(() => {
+ return this.filters().filter((filter) => this.shouldShowFilter(filter));
+ });
+
+ readonly hasVisibleFilters = computed(() => {
+ return this.visibleFilters().length > 0;
+ });
+
+ shouldShowFilter(filter: DiscoverableFilter): boolean {
+ if (!filter || !filter.key) return false;
+
+ if (filter.key === 'resourceType' || filter.key === 'accessService') {
+ return Boolean(filter.options && filter.options.length > 0);
+ }
+
+ return Boolean(
+ (filter.resultCount && filter.resultCount > 0) ||
+ (filter.options && filter.options.length > 0) ||
+ filter.hasOptions ||
+ (filter.selectedValues && filter.selectedValues.length > 0)
+ );
+ }
+
+ onAccordionToggle(filterKey: string | number | string[] | number[]): void {
+ if (!filterKey) return;
+
+ const key = Array.isArray(filterKey) ? filterKey[0]?.toString() : filterKey.toString();
+ const selectedFilter = this.filters().find((filter) => filter.key === key);
+
+ if (selectedFilter) {
+ this.expandedFilters.update((expanded) => {
+ const newExpanded = new Set(expanded);
+ if (newExpanded.has(key)) {
+ newExpanded.delete(key);
+ } else {
+ newExpanded.add(key);
+ }
+ return newExpanded;
+ });
+
+ if (!selectedFilter.options?.length && selectedFilter.hasOptions) {
+ this.loadFilterOptions.emit({
+ filterType: key as ReusableFilterType,
+ filter: selectedFilter,
+ });
+ }
+ }
+ }
+
+ onFilterChanged(filterType: string, value: string | null): void {
+ this.filterValueChanged.emit({ filterType, value });
+ }
+
+ getFilterOptions(filter: DiscoverableFilter): SelectOption[] {
+ return filter.options || [];
+ }
+
+ isFilterLoading(filter: DiscoverableFilter): boolean {
+ return filter.isLoading || false;
+ }
+
+ getSelectedValue(filterKey: string): string | null {
+ return this.selectedValues()[filterKey] || null;
+ }
+
+ getFilterPlaceholder(filterKey: string): string {
+ return this.FILTER_PLACEHOLDERS[filterKey] || '';
+ }
+
+ getFilterDescription(filter: DiscoverableFilter): string | null {
+ return filter.description || null;
+ }
+
+ getFilterHelpLink(filter: DiscoverableFilter): string | null {
+ return filter.helpLink || null;
+ }
+
+ getFilterHelpLinkText(filter: DiscoverableFilter): string | null {
+ return filter.helpLinkText || '';
+ }
+
+ getFilterLabel(filter: DiscoverableFilter): string {
+ return filter.label || filter.key || '';
+ }
+
+ hasFilterContent(filter: DiscoverableFilter): boolean {
+ return !!(
+ filter.description ||
+ filter.helpLink ||
+ filter.resultCount ||
+ filter.options?.length ||
+ filter.hasOptions
+ );
+ }
+}
diff --git a/src/app/shared/components/search-results-container/search-results-container.component.html b/src/app/shared/components/search-results-container/search-results-container.component.html
new file mode 100644
index 00000000..7e98a712
--- /dev/null
+++ b/src/app/shared/components/search-results-container/search-results-container.component.html
@@ -0,0 +1,129 @@
+
+
+
+
+
+ @if (searchCount() > 10000) {
+ 10 000+ {{ 'collections.searchResults.results' | translate }}
+ } @else if (searchCount() > 0) {
+ {{ searchCount() }} {{ 'collections.searchResults.results' | translate }}
+ } @else {
+ 0 {{ 'collections.searchResults.results' | translate }}
+ }
+
+
+
+
+
{{ 'collections.filters.sortBy' | translate }}:
+
+
+
+ @if (isAnyFilterOptions()) {
+
![filter by]()
+ }
+
![sort by]()
+
+
+
+@if (isFiltersOpen()) {
+
+
+
+} @else if (isSortingOpen()) {
+
+ @for (option of searchSortingOptions; track option.value) {
+
+ {{ option.label }}
+
+ }
+
+} @else {
+ @if (hasSelectedValues()) {
+
+
+
+ }
+}
+
+
+
+
+
+
+
+
+
+ @if (items.length > 0) {
+ @for (item of items; track item.id) {
+
+ }
+
+
+ @if (first() && prev()) {
+
+ }
+
+
+
+
+
+
+
+ }
+
+
+
+
diff --git a/src/app/shared/components/search-results-container/search-results-container.component.scss b/src/app/shared/components/search-results-container/search-results-container.component.scss
new file mode 100644
index 00000000..b9d7f895
--- /dev/null
+++ b/src/app/shared/components/search-results-container/search-results-container.component.scss
@@ -0,0 +1,16 @@
+.result-count {
+ color: var(--pr-blue-1);
+}
+
+.sort-card {
+ &:hover {
+ background-color: var(--grey-3);
+ border-color: var(--pr-blue-1);
+ }
+
+ &.card-selected {
+ background-color: var(--pr-blue-1);
+ color: var(--white);
+ border-color: var(--pr-blue-1);
+ }
+}
diff --git a/src/app/shared/components/search-results-container/search-results-container.component.spec.ts b/src/app/shared/components/search-results-container/search-results-container.component.spec.ts
new file mode 100644
index 00000000..b6629ebf
--- /dev/null
+++ b/src/app/shared/components/search-results-container/search-results-container.component.spec.ts
@@ -0,0 +1,475 @@
+import { ComponentRef } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+
+import { SEARCH_TAB_OPTIONS, searchSortingOptions } from '@shared/constants';
+import { ResourceTab } from '@shared/enums';
+import { Resource } from '@shared/models';
+
+import { SearchResultsContainerComponent } from './search-results-container.component';
+
+describe('SearchResultsContainerComponent', () => {
+ let component: SearchResultsContainerComponent;
+ let fixture: ComponentFixture;
+ let componentRef: ComponentRef;
+
+ const mockResources: Resource[] = [
+ {
+ id: '1',
+ title: 'Test Resource 1',
+ description: 'Test Description 1',
+ type: 'project',
+ url: 'http://test1.com',
+ contributors: [],
+ tags: [],
+ dateCreated: new Date('2023-01-01'),
+ dateModified: new Date('2023-01-01'),
+ },
+ {
+ id: '2',
+ title: 'Test Resource 2',
+ description: 'Test Description 2',
+ type: 'registration',
+ url: 'http://test2.com',
+ contributors: [],
+ tags: [],
+ dateCreated: new Date('2023-01-02'),
+ dateModified: new Date('2023-01-02'),
+ },
+ ];
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [SearchResultsContainerComponent, NoopAnimationsModule],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(SearchResultsContainerComponent);
+ component = fixture.componentInstance;
+ componentRef = fixture.componentRef;
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('Component Initialization', () => {
+ it('should have default input values', () => {
+ expect(component.resources()).toEqual([]);
+ expect(component.searchCount()).toBe(0);
+ expect(component.selectedSort()).toBe('');
+ expect(component.selectedTab()).toBe(ResourceTab.All);
+ expect(component.selectedValues()).toEqual({});
+ expect(component.first()).toBeNull();
+ expect(component.prev()).toBeNull();
+ expect(component.next()).toBeNull();
+ expect(component.isFiltersOpen()).toBe(false);
+ expect(component.isSortingOpen()).toBe(false);
+ });
+
+ it('should have access to constants', () => {
+ expect(component['searchSortingOptions']).toBe(searchSortingOptions);
+ expect(component['ResourceTab']).toBe(ResourceTab);
+ expect(component['tabsOptions']).toBe(SEARCH_TAB_OPTIONS);
+ });
+ });
+
+ describe('Computed Properties', () => {
+ it('should compute hasSelectedValues correctly when no values are selected', () => {
+ componentRef.setInput('selectedValues', {});
+ expect(component['hasSelectedValues']()).toBe(false);
+ });
+
+ it('should compute hasSelectedValues correctly when values are selected', () => {
+ componentRef.setInput('selectedValues', { subject: 'psychology', type: 'project' });
+ expect(component['hasSelectedValues']()).toBe(true);
+ });
+
+ it('should compute hasSelectedValues correctly when some values are null or empty', () => {
+ componentRef.setInput('selectedValues', { subject: null, type: '', category: 'science' });
+ expect(component['hasSelectedValues']()).toBe(true);
+ });
+
+ it('should compute hasSelectedValues correctly when all values are null or empty', () => {
+ componentRef.setInput('selectedValues', { subject: null, type: '', category: '' });
+ expect(component['hasSelectedValues']()).toBe(false);
+ });
+
+ it('should compute hasFilters correctly', () => {
+ expect(component['hasFilters']()).toBe(true);
+ });
+ });
+
+ describe('Display Logic', () => {
+ beforeEach(() => {
+ componentRef.setInput('resources', mockResources);
+ componentRef.setInput('searchCount', 2);
+ componentRef.setInput('selectedSort', 'relevance');
+ componentRef.setInput('selectedTab', ResourceTab.All);
+ fixture.detectChanges();
+ });
+
+ it('should display correct search count when count is normal', () => {
+ const countElement = fixture.debugElement.query(By.css('h3'));
+ expect(countElement.nativeElement.textContent.trim()).toBe('2 results');
+ });
+
+ it('should display 10000+ when search count is above 10000', () => {
+ componentRef.setInput('searchCount', 15000);
+ fixture.detectChanges();
+
+ const countElement = fixture.debugElement.query(By.css('h3'));
+ expect(countElement.nativeElement.textContent.trim()).toBe('10 000+ results');
+ });
+
+ it('should display 0 results when search count is 0', () => {
+ componentRef.setInput('searchCount', 0);
+ fixture.detectChanges();
+
+ const countElement = fixture.debugElement.query(By.css('h3'));
+ expect(countElement.nativeElement.textContent.trim()).toBe('0 results');
+ });
+
+ it('should display mobile dropdown when on mobile', () => {
+ const mobileDropdown = fixture.debugElement.query(By.css('p-select.text-center.inline-flex.md\\:hidden'));
+ expect(mobileDropdown).toBeTruthy();
+ });
+
+ it('should display desktop sorting dropdown', () => {
+ const desktopDropdown = fixture.debugElement.query(By.css('p-select.no-border-dropdown'));
+ expect(desktopDropdown).toBeTruthy();
+ });
+
+ it('should show filter chips when hasSelectedValues is true', () => {
+ componentRef.setInput('selectedValues', { subject: 'psychology' });
+ fixture.detectChanges();
+
+ const filterChipsSlot = fixture.debugElement.query(By.css('[slot="filter-chips"]'));
+ expect(filterChipsSlot).toBeTruthy();
+ });
+
+ it('should not show filter chips when hasSelectedValues is false', () => {
+ componentRef.setInput('selectedValues', {});
+ fixture.detectChanges();
+
+ const filterChipsContainer = fixture.debugElement.query(By.css('.mb-3'));
+ expect(filterChipsContainer).toBeFalsy();
+ });
+ });
+
+ describe('Conditional Display States', () => {
+ it('should display filters when isFiltersOpen is true', () => {
+ componentRef.setInput('isFiltersOpen', true);
+ fixture.detectChanges();
+
+ const filtersContainer = fixture.debugElement.query(By.css('.filter-full-size'));
+ expect(filtersContainer).toBeTruthy();
+ });
+
+ it('should display sorting options when isSortingOpen is true', () => {
+ componentRef.setInput('isSortingOpen', true);
+ fixture.detectChanges();
+
+ const sortingContainer = fixture.debugElement.query(By.css('.flex.flex-column.p-5.pt-1.row-gap-3'));
+ expect(sortingContainer).toBeTruthy();
+ });
+
+ it('should display sorting cards when isSortingOpen is true', () => {
+ componentRef.setInput('isSortingOpen', true);
+ fixture.detectChanges();
+
+ const sortCards = fixture.debugElement.queryAll(By.css('.sort-card'));
+ expect(sortCards.length).toBe(searchSortingOptions.length);
+ });
+
+ it('should highlight selected sorting option', () => {
+ componentRef.setInput('isSortingOpen', true);
+ componentRef.setInput('selectedSort', 'relevance');
+ fixture.detectChanges();
+
+ const selectedCard = fixture.debugElement.query(By.css('.sort-card.card-selected'));
+ expect(selectedCard).toBeTruthy();
+ });
+
+ it('should display main content when neither filters nor sorting are open', () => {
+ componentRef.setInput('isFiltersOpen', false);
+ componentRef.setInput('isSortingOpen', false);
+ componentRef.setInput('resources', mockResources);
+ fixture.detectChanges();
+
+ const dataView = fixture.debugElement.query(By.css('p-dataView'));
+ expect(dataView).toBeTruthy();
+ });
+ });
+
+ describe('Method Testing', () => {
+ it('should emit sortChanged when selectSort is called', () => {
+ spyOn(component.sortChanged, 'emit');
+
+ component.selectSort('relevance');
+
+ expect(component.sortChanged.emit).toHaveBeenCalledWith('relevance');
+ });
+
+ it('should emit tabChanged when selectTab is called', () => {
+ spyOn(component.tabChanged, 'emit');
+
+ component.selectTab(ResourceTab.Projects);
+
+ expect(component.tabChanged.emit).toHaveBeenCalledWith(ResourceTab.Projects);
+ });
+
+ it('should emit pageChanged when switchPage is called with valid link', () => {
+ spyOn(component.pageChanged, 'emit');
+
+ component.switchPage('http://example.com/page2');
+
+ expect(component.pageChanged.emit).toHaveBeenCalledWith('http://example.com/page2');
+ });
+
+ it('should not emit pageChanged when switchPage is called with null', () => {
+ spyOn(component.pageChanged, 'emit');
+
+ component.switchPage(null);
+
+ expect(component.pageChanged.emit).not.toHaveBeenCalled();
+ });
+
+ it('should emit filtersToggled when openFilters is called', () => {
+ spyOn(component.filtersToggled, 'emit');
+
+ component.openFilters();
+
+ expect(component.filtersToggled.emit).toHaveBeenCalled();
+ });
+
+ it('should emit sortingToggled when openSorting is called', () => {
+ spyOn(component.sortingToggled, 'emit');
+
+ component.openSorting();
+
+ expect(component.sortingToggled.emit).toHaveBeenCalled();
+ });
+
+ it('should return true for isAnyFilterOptions', () => {
+ expect(component.isAnyFilterOptions()).toBe(true);
+ });
+ });
+
+ describe('Pagination', () => {
+ beforeEach(() => {
+ componentRef.setInput('resources', mockResources);
+ componentRef.setInput('first', 'http://example.com/page1');
+ componentRef.setInput('prev', 'http://example.com/prev');
+ componentRef.setInput('next', 'http://example.com/next');
+ fixture.detectChanges();
+ });
+
+ it('should display pagination buttons when navigation links are available', () => {
+ const paginationButtons = fixture.debugElement.queryAll(By.css('p-button'));
+ expect(paginationButtons.length).toBeGreaterThan(0);
+ });
+
+ it('should handle first page button click', () => {
+ spyOn(component, 'switchPage');
+
+ const firstButton = fixture.debugElement.query(By.css('p-button[icon="fas fa-angles-left"]'));
+ firstButton.nativeElement.click();
+
+ expect(component.switchPage).toHaveBeenCalledWith('http://example.com/page1');
+ });
+
+ it('should handle previous page button click', () => {
+ spyOn(component, 'switchPage');
+
+ const prevButton = fixture.debugElement.query(By.css('p-button[icon="fas fa-angle-left"]'));
+ prevButton.nativeElement.click();
+
+ expect(component.switchPage).toHaveBeenCalledWith('http://example.com/prev');
+ });
+
+ it('should handle next page button click', () => {
+ spyOn(component, 'switchPage');
+
+ const nextButton = fixture.debugElement.query(By.css('p-button[icon="fas fa-angle-right"]'));
+ nextButton.nativeElement.click();
+
+ expect(component.switchPage).toHaveBeenCalledWith('http://example.com/next');
+ });
+
+ it('should disable previous button when prev is null', () => {
+ componentRef.setInput('prev', null);
+ fixture.detectChanges();
+
+ const prevButton = fixture.debugElement.query(By.css('p-button[icon="fas fa-angle-left"]'));
+ expect(prevButton.nativeElement.disabled).toBe(true);
+ });
+
+ it('should disable next button when next is null', () => {
+ componentRef.setInput('next', null);
+ fixture.detectChanges();
+
+ const nextButton = fixture.debugElement.query(By.css('p-button[icon="fas fa-angle-right"]'));
+ expect(nextButton.nativeElement.disabled).toBe(true);
+ });
+ });
+
+ describe('Event Handling', () => {
+ beforeEach(() => {
+ componentRef.setInput('resources', mockResources);
+ fixture.detectChanges();
+ });
+
+ it('should handle sort selection from dropdown', () => {
+ spyOn(component, 'selectSort');
+
+ const sortDropdown = fixture.debugElement.query(By.css('p-select.no-border-dropdown'));
+ sortDropdown.triggerEventHandler('ngModelChange', 'date');
+
+ expect(component.selectSort).toHaveBeenCalledWith('date');
+ });
+
+ it('should handle tab selection from mobile dropdown', () => {
+ spyOn(component, 'selectTab');
+
+ const tabDropdown = fixture.debugElement.query(By.css('p-select.text-center.inline-flex.md\\:hidden'));
+ tabDropdown.triggerEventHandler('ngModelChange', ResourceTab.Projects);
+
+ expect(component.selectTab).toHaveBeenCalledWith(ResourceTab.Projects);
+ });
+
+ it('should handle filter icon click', () => {
+ spyOn(component, 'openFilters');
+
+ const filterIcon = fixture.debugElement.query(By.css('img[alt="filter by"]'));
+ filterIcon.nativeElement.click();
+
+ expect(component.openFilters).toHaveBeenCalled();
+ });
+
+ it('should handle sort icon click', () => {
+ spyOn(component, 'openSorting');
+
+ const sortIcon = fixture.debugElement.query(By.css('img[alt="sort by"]'));
+ sortIcon.nativeElement.click();
+
+ expect(component.openSorting).toHaveBeenCalled();
+ });
+
+ it('should handle filter icon keyboard enter', () => {
+ spyOn(component, 'openFilters');
+
+ const filterIcon = fixture.debugElement.query(By.css('img[alt="filter by"]'));
+ filterIcon.triggerEventHandler('keydown.enter', {});
+
+ expect(component.openFilters).toHaveBeenCalled();
+ });
+
+ it('should handle sort icon keyboard enter', () => {
+ spyOn(component, 'openSorting');
+
+ const sortIcon = fixture.debugElement.query(By.css('img[alt="sort by"]'));
+ sortIcon.triggerEventHandler('keydown.enter', {});
+
+ expect(component.openSorting).toHaveBeenCalled();
+ });
+ });
+
+ describe('Sorting Card Interaction', () => {
+ beforeEach(() => {
+ componentRef.setInput('isSortingOpen', true);
+ componentRef.setInput('selectedSort', 'relevance');
+ fixture.detectChanges();
+ });
+
+ it('should handle sort card click', () => {
+ spyOn(component, 'selectSort');
+
+ const sortCard = fixture.debugElement.query(By.css('.sort-card'));
+ sortCard.nativeElement.click();
+
+ expect(component.selectSort).toHaveBeenCalledWith(searchSortingOptions[0].value);
+ });
+
+ it('should handle sort card keyboard enter', () => {
+ spyOn(component, 'selectSort');
+
+ const sortCard = fixture.debugElement.query(By.css('.sort-card'));
+ sortCard.triggerEventHandler('keydown.enter', {});
+
+ expect(component.selectSort).toHaveBeenCalledWith(searchSortingOptions[0].value);
+ });
+ });
+
+ describe('Resource Display', () => {
+ beforeEach(() => {
+ componentRef.setInput('resources', mockResources);
+ fixture.detectChanges();
+ });
+
+ it('should display resources in data view', () => {
+ const dataView = fixture.debugElement.query(By.css('p-dataView'));
+ expect(dataView).toBeTruthy();
+ expect(dataView.componentInstance.value).toEqual(mockResources);
+ });
+
+ it('should set correct rows per page', () => {
+ const dataView = fixture.debugElement.query(By.css('p-dataView'));
+ expect(dataView.componentInstance.rows).toBe(10);
+ });
+ });
+
+ describe('Accessibility', () => {
+ beforeEach(() => {
+ fixture.detectChanges();
+ });
+
+ it('should have proper tabindex on interactive elements', () => {
+ const filterIcon = fixture.debugElement.query(By.css('img[alt="filter by"]'));
+ const sortIcon = fixture.debugElement.query(By.css('img[alt="sort by"]'));
+
+ expect(filterIcon.nativeElement.getAttribute('tabindex')).toBe('0');
+ expect(sortIcon.nativeElement.getAttribute('tabindex')).toBe('0');
+ });
+
+ it('should have proper role attributes on interactive elements', () => {
+ const filterIcon = fixture.debugElement.query(By.css('img[alt="filter by"]'));
+ const sortIcon = fixture.debugElement.query(By.css('img[alt="sort by"]'));
+
+ expect(filterIcon.nativeElement.getAttribute('role')).toBe('button');
+ expect(sortIcon.nativeElement.getAttribute('role')).toBe('button');
+ });
+
+ it('should have proper alt text on icons', () => {
+ const filterIcon = fixture.debugElement.query(By.css('img[alt="filter by"]'));
+ const sortIcon = fixture.debugElement.query(By.css('img[alt="sort by"]'));
+
+ expect(filterIcon.nativeElement.getAttribute('alt')).toBe('filter by');
+ expect(sortIcon.nativeElement.getAttribute('alt')).toBe('sort by');
+ });
+ });
+
+ describe('Edge Cases', () => {
+ it('should handle empty resources array', () => {
+ componentRef.setInput('resources', []);
+ fixture.detectChanges();
+
+ const dataView = fixture.debugElement.query(By.css('p-dataView'));
+ expect(dataView.componentInstance.value).toEqual([]);
+ });
+
+ it('should handle undefined selected values', () => {
+ componentRef.setInput('selectedValues', undefined);
+ expect(() => component['hasSelectedValues']()).not.toThrow();
+ });
+
+ it('should handle null navigation links', () => {
+ componentRef.setInput('first', null);
+ componentRef.setInput('prev', null);
+ componentRef.setInput('next', null);
+ fixture.detectChanges();
+
+ expect(() => fixture.detectChanges()).not.toThrow();
+ });
+ });
+});
diff --git a/src/app/shared/components/search-results-container/search-results-container.component.ts b/src/app/shared/components/search-results-container/search-results-container.component.ts
new file mode 100644
index 00000000..070ae6d3
--- /dev/null
+++ b/src/app/shared/components/search-results-container/search-results-container.component.ts
@@ -0,0 +1,92 @@
+import { TranslatePipe } from '@ngx-translate/core';
+
+import { Button } from 'primeng/button';
+import { DataView } from 'primeng/dataview';
+import { Select } from 'primeng/select';
+
+import { NgOptimizedImage } from '@angular/common';
+import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+
+import { Primitive } from '@core/helpers';
+import { SEARCH_TAB_OPTIONS, searchSortingOptions } from '@shared/constants';
+import { ResourceTab } from '@shared/enums';
+import { Resource } from '@shared/models';
+
+import { ResourceCardComponent } from '../resource-card/resource-card.component';
+import { SelectComponent } from '../select/select.component';
+
+@Component({
+ selector: 'osf-search-results-container',
+ imports: [
+ FormsModule,
+ NgOptimizedImage,
+ Button,
+ DataView,
+ Select,
+ ResourceCardComponent,
+ TranslatePipe,
+ SelectComponent,
+ ],
+ templateUrl: './search-results-container.component.html',
+ styleUrl: './search-results-container.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class SearchResultsContainerComponent {
+ resources = input([]);
+ searchCount = input(0);
+ selectedSort = input('');
+ selectedTab = input(ResourceTab.All);
+ selectedValues = input>({});
+ first = input(null);
+ prev = input(null);
+ next = input(null);
+ isFiltersOpen = input(false);
+ isSortingOpen = input(false);
+
+ sortChanged = output();
+ tabChanged = output();
+ pageChanged = output();
+ filtersToggled = output();
+ sortingToggled = output();
+
+ protected readonly searchSortingOptions = searchSortingOptions;
+ protected readonly ResourceTab = ResourceTab;
+
+ protected readonly tabsOptions = SEARCH_TAB_OPTIONS;
+
+ protected readonly hasSelectedValues = computed(() => {
+ const values = this.selectedValues();
+ return Object.values(values).some((value) => value !== null && value !== '');
+ });
+
+ protected readonly hasFilters = computed(() => {
+ return true;
+ });
+
+ selectSort(value: string): void {
+ this.sortChanged.emit(value);
+ }
+
+ selectTab(value?: ResourceTab): void {
+ this.tabChanged.emit((value ? value : this.selectedTab()) as ResourceTab);
+ }
+
+ switchPage(link: string | null): void {
+ if (link != null) {
+ this.pageChanged.emit(link);
+ }
+ }
+
+ openFilters(): void {
+ this.filtersToggled.emit();
+ }
+
+ openSorting(): void {
+ this.sortingToggled.emit();
+ }
+
+ isAnyFilterOptions(): boolean {
+ return this.hasFilters();
+ }
+}
diff --git a/src/app/shared/constants/filter-placeholders.ts b/src/app/shared/constants/filter-placeholders.ts
new file mode 100644
index 00000000..de0ec67e
--- /dev/null
+++ b/src/app/shared/constants/filter-placeholders.ts
@@ -0,0 +1,11 @@
+export const FILTER_PLACEHOLDERS: Record = {
+ affiliation: 'common.search.filterPlaceholders.affiliation',
+ subject: 'common.search.filterPlaceholders.subject',
+ funder: 'common.search.filterPlaceholders.funder',
+ rights: 'common.search.filterPlaceholders.rights',
+ publisher: 'common.search.filterPlaceholders.publisher',
+ isPartOfCollection: 'common.search.filterPlaceholders.isPartOfCollection',
+ dateCreated: 'common.search.filterPlaceholders.dateCreated',
+ creator: 'common.search.filterPlaceholders.creator',
+ resourceType: 'common.search.filterPlaceholders.resourceType',
+};
diff --git a/src/app/shared/constants/index.ts b/src/app/shared/constants/index.ts
index b7598dcf..2c0a7d77 100644
--- a/src/app/shared/constants/index.ts
+++ b/src/app/shared/constants/index.ts
@@ -1,6 +1,7 @@
export * from './addon-terms.const';
export * from './addons-category-options.const';
export * from './addons-tab-options.const';
+export * from './filter-placeholders';
export * from './input-limits.const';
export * from './input-validation-messages.const';
export * from './language.const';
diff --git a/src/app/shared/enums/index.ts b/src/app/shared/enums/index.ts
index b953b178..5af34c16 100644
--- a/src/app/shared/enums/index.ts
+++ b/src/app/shared/enums/index.ts
@@ -11,6 +11,7 @@ export * from './get-resources-request-type.enum';
export * from './profile-addons-stepper.enum';
export * from './resource-tab.enum';
export * from './resource-type.enum';
+export * from './reusable-filter-type.enum';
export * from './share-indexing.enum';
export * from './sort-order.enum';
export * from './sort-type.enum';
diff --git a/src/app/shared/enums/reusable-filter-type.enum.ts b/src/app/shared/enums/reusable-filter-type.enum.ts
new file mode 100644
index 00000000..7c1dbba8
--- /dev/null
+++ b/src/app/shared/enums/reusable-filter-type.enum.ts
@@ -0,0 +1,13 @@
+export enum ReusableFilterType {
+ AFFILIATION = 'affiliation',
+ ACCESS_SERVICE = 'accessService',
+ RESOURCE_TYPE = 'resourceType',
+ SUBJECT = 'subject',
+ FUNDER = 'funder',
+ DATE_CREATED = 'dateCreated',
+ CREATOR = 'creator',
+ IS_PART_OF_COLLECTION = 'isPartOfCollection',
+ PUBLISHER = 'publisher',
+ RIGHTS = 'rights',
+ RESOURCE_NATURE = 'resourceNature',
+}
diff --git a/src/app/shared/mappers/filters/filter-option.mapper.ts b/src/app/shared/mappers/filters/filter-option.mapper.ts
new file mode 100644
index 00000000..2ef5a1ab
--- /dev/null
+++ b/src/app/shared/mappers/filters/filter-option.mapper.ts
@@ -0,0 +1,15 @@
+import { ApiData } from '@osf/core/models';
+import { FilterOptionAttributes, FilterOptionMetadata, SelectOption } from '@shared/models';
+
+export type FilterOptionItem = ApiData;
+
+export function mapFilterOption(item: FilterOptionItem): SelectOption {
+ const metadata: FilterOptionMetadata = item.attributes.resourceMetadata;
+ const name = metadata.name?.[0]?.['@value'] || metadata.title?.[0]?.['@value'] || '';
+ const id = metadata['@id'];
+
+ return {
+ label: name,
+ value: id,
+ };
+}
diff --git a/src/app/shared/mappers/filters/index.ts b/src/app/shared/mappers/filters/index.ts
index 7b93b15d..e062214b 100644
--- a/src/app/shared/mappers/filters/index.ts
+++ b/src/app/shared/mappers/filters/index.ts
@@ -1,9 +1,11 @@
export * from './creators.mappers';
export * from './date-created.mapper';
+export * from './filter-option.mapper';
export * from './funder.mapper';
export * from './institution.mapper';
export * from './license.mapper';
export * from './part-of-collection.mapper';
export * from './provider.mapper';
export * from './resource-type.mapper';
+export * from './reusable-filter.mapper';
export * from './subject.mapper';
diff --git a/src/app/shared/mappers/filters/reusable-filter.mapper.ts b/src/app/shared/mappers/filters/reusable-filter.mapper.ts
new file mode 100644
index 00000000..2a08d29c
--- /dev/null
+++ b/src/app/shared/mappers/filters/reusable-filter.mapper.ts
@@ -0,0 +1,192 @@
+import { ApiData } from '@osf/core/models';
+import { DiscoverableFilter } from '@shared/models';
+
+export interface RelatedPropertyPathAttributes {
+ propertyPathKey: string;
+ propertyPath: {
+ '@id': string;
+ displayLabel: {
+ '@language': string;
+ '@value': string;
+ }[];
+ description?: {
+ '@language': string;
+ '@value': string;
+ }[];
+ link?: {
+ '@language': string;
+ '@value': string;
+ }[];
+ linkText?: {
+ '@language': string;
+ '@value': string;
+ }[];
+ resourceType: {
+ '@id': string;
+ }[];
+ shortFormLabel: {
+ '@language': string;
+ '@value': string;
+ }[];
+ }[];
+ suggestedFilterOperator: string;
+ cardSearchResultCount: number;
+ osfmapPropertyPath: string[];
+}
+
+export interface AppliedFilter {
+ propertyPathKey: string;
+ propertyPathSet: {
+ '@id': string;
+ displayLabel?: {
+ '@language': string;
+ '@value': string;
+ }[];
+ description?: {
+ '@language': string;
+ '@value': string;
+ }[];
+ link?: {
+ '@language': string;
+ '@value': string;
+ }[];
+ linkText?: {
+ '@language': string;
+ '@value': string;
+ }[];
+ resourceType: {
+ '@id': string;
+ }[];
+ shortFormLabel: {
+ '@language': string;
+ '@value': string;
+ }[];
+ }[][];
+ filterValueSet: {
+ '@id': string;
+ displayLabel?: {
+ '@language': string;
+ '@value': string;
+ }[];
+ resourceType?: {
+ '@id': string;
+ }[];
+ shortFormLabel?: {
+ '@language': string;
+ '@value': string;
+ }[];
+ }[];
+ filterType: {
+ '@id': string;
+ };
+}
+
+export type RelatedPropertyPathItem = ApiData;
+
+export function ReusableFilterMapper(item: RelatedPropertyPathItem): DiscoverableFilter {
+ const key = item.attributes.propertyPathKey;
+ const propertyPath = item.attributes.propertyPath?.[0];
+ const label = propertyPath?.displayLabel?.[0]?.['@value'] ?? key;
+ const operator = item.attributes.suggestedFilterOperator ?? 'any-of';
+ const description = propertyPath?.description?.[0]?.['@value'];
+ const helpLink = propertyPath?.link?.[0]?.['@value'];
+ const helpLinkText = propertyPath?.linkText?.[0]?.['@value'];
+
+ const type: DiscoverableFilter['type'] = key === 'dateCreated' ? 'date' : key === 'creator' ? 'checkbox' : 'select';
+
+ const shouldLoadOptions = [
+ 'subject',
+ 'rights',
+ 'resourceNature',
+ 'affiliation',
+ 'publisher',
+ 'funder',
+ 'isPartOfCollection',
+ ].includes(key);
+
+ return {
+ key,
+ label,
+ type,
+ operator,
+ options: [],
+ description,
+ helpLink,
+ helpLinkText,
+ resultCount: item.attributes.cardSearchResultCount,
+ isLoading: false,
+ isLoaded: false,
+ hasOptions: shouldLoadOptions,
+ loadOptionsOnExpand: shouldLoadOptions,
+ };
+}
+
+export function AppliedFilterMapper(appliedFilter: AppliedFilter): DiscoverableFilter {
+ const key = appliedFilter.propertyPathKey;
+ const propertyPath = appliedFilter.propertyPathSet?.[0]?.[0];
+ const label = propertyPath?.displayLabel?.[0]?.['@value'] ?? key;
+ const operator = appliedFilter.filterType?.['@id']?.replace('trove:', '') ?? 'any-of';
+ const description = propertyPath?.description?.[0]?.['@value'];
+ const helpLink = propertyPath?.link?.[0]?.['@value'];
+ const helpLinkText = propertyPath?.linkText?.[0]?.['@value'];
+
+ const type: DiscoverableFilter['type'] = key === 'dateCreated' ? 'date' : key === 'creator' ? 'checkbox' : 'select';
+
+ const shouldLoadOptions = [
+ 'subject',
+ 'rights',
+ 'resourceNature',
+ 'affiliation',
+ 'publisher',
+ 'funder',
+ 'isPartOfCollection',
+ ].includes(key);
+
+ return {
+ key,
+ label,
+ type,
+ operator,
+ description,
+ helpLink,
+ helpLinkText,
+ isLoading: false,
+ hasOptions: shouldLoadOptions,
+ loadOptionsOnExpand: shouldLoadOptions,
+ };
+}
+
+export function CombinedFilterMapper(
+ appliedFilters: AppliedFilter[] = [],
+ availableFilters: RelatedPropertyPathItem[] = []
+): DiscoverableFilter[] {
+ const filterMap = new Map();
+
+ appliedFilters.forEach((appliedFilter) => {
+ const filter = AppliedFilterMapper(appliedFilter);
+ filterMap.set(filter.key, filter);
+ });
+
+ availableFilters.forEach((availableFilter) => {
+ const key = availableFilter.attributes.propertyPathKey;
+ const existingFilter = filterMap.get(key);
+
+ if (existingFilter) {
+ existingFilter.resultCount = availableFilter.attributes.cardSearchResultCount;
+ if (!existingFilter.description) {
+ existingFilter.description = availableFilter.attributes.propertyPath?.[0]?.description?.[0]?.['@value'];
+ }
+ if (!existingFilter.helpLink) {
+ existingFilter.helpLink = availableFilter.attributes.propertyPath?.[0]?.link?.[0]?.['@value'];
+ }
+ if (!existingFilter.helpLinkText) {
+ existingFilter.helpLinkText = availableFilter.attributes.propertyPath?.[0]?.linkText?.[0]?.['@value'];
+ }
+ } else {
+ const filter = ReusableFilterMapper(availableFilter);
+ filterMap.set(filter.key, filter);
+ }
+ });
+
+ return Array.from(filterMap.values());
+}
diff --git a/src/app/shared/models/index.ts b/src/app/shared/models/index.ts
index bebcc60f..b3e84070 100644
--- a/src/app/shared/models/index.ts
+++ b/src/app/shared/models/index.ts
@@ -20,6 +20,7 @@ export * from './node-subject.model';
export * from './paginated-data.model';
export * from './query-params.model';
export * from './resource-card';
+export * from './search';
export * from './select-option.model';
export * from './severity.type';
export * from './social-icon.model';
diff --git a/src/app/shared/models/institutions/institution-json-api.model.ts b/src/app/shared/models/institutions/institution-json-api.model.ts
new file mode 100644
index 00000000..e5f78877
--- /dev/null
+++ b/src/app/shared/models/institutions/institution-json-api.model.ts
@@ -0,0 +1,10 @@
+import { Institution, InstitutionLinks } from '@shared/models';
+
+export interface InstitutionJsonApiModel {
+ data: {
+ attributes: Institution;
+ id: string;
+ links: InstitutionLinks;
+ };
+ meta: { version: string };
+}
diff --git a/src/app/shared/models/search/discaverable-filter.model.ts b/src/app/shared/models/search/discaverable-filter.model.ts
new file mode 100644
index 00000000..a7ce461a
--- /dev/null
+++ b/src/app/shared/models/search/discaverable-filter.model.ts
@@ -0,0 +1,18 @@
+import { SelectOption } from '@shared/models';
+
+export interface DiscoverableFilter {
+ key: string;
+ label: string;
+ type: 'select' | 'date' | 'checkbox';
+ operator: string;
+ options?: SelectOption[];
+ selectedValues?: SelectOption[];
+ description?: string;
+ helpLink?: string;
+ helpLinkText?: string;
+ resultCount?: number;
+ isLoading?: boolean;
+ isLoaded?: boolean;
+ hasOptions?: boolean;
+ loadOptionsOnExpand?: boolean;
+}
diff --git a/src/app/shared/models/search/filter-option.model.ts b/src/app/shared/models/search/filter-option.model.ts
new file mode 100644
index 00000000..0d617189
--- /dev/null
+++ b/src/app/shared/models/search/filter-option.model.ts
@@ -0,0 +1,10 @@
+export interface FilterOptionMetadata {
+ '@id': string;
+ name: { '@value': string }[];
+ resourceType: { '@id': string }[];
+ title?: { '@value': string }[];
+}
+
+export interface FilterOptionAttributes {
+ resourceMetadata: FilterOptionMetadata;
+}
diff --git a/src/app/shared/models/search/filter-options-response.model.ts b/src/app/shared/models/search/filter-options-response.model.ts
new file mode 100644
index 00000000..b95ff613
--- /dev/null
+++ b/src/app/shared/models/search/filter-options-response.model.ts
@@ -0,0 +1,28 @@
+import { ApiData } from '@osf/core/models';
+
+import { FilterOptionAttributes } from './filter-option.model';
+
+export interface FilterOptionsResponseData {
+ type: string;
+ id: string;
+ attributes: Record;
+ relationships?: Record;
+}
+
+export interface FilterOptionsResponseJsonApi {
+ data: FilterOptionsResponseData;
+ included?: FilterOptionItem[];
+ links?: {
+ first?: string;
+ next?: string;
+ prev?: string;
+ last?: string;
+ };
+ meta?: {
+ total?: number;
+ page?: number;
+ 'per-page'?: number;
+ };
+}
+
+export type FilterOptionItem = ApiData;
diff --git a/src/app/shared/models/search/index.ts b/src/app/shared/models/search/index.ts
new file mode 100644
index 00000000..536356e7
--- /dev/null
+++ b/src/app/shared/models/search/index.ts
@@ -0,0 +1,3 @@
+export * from './discaverable-filter.model';
+export * from './filter-option.model';
+export * from './filter-options-response.model';
diff --git a/src/app/shared/services/institutions.service.ts b/src/app/shared/services/institutions.service.ts
index 3331b95a..56f70fad 100644
--- a/src/app/shared/services/institutions.service.ts
+++ b/src/app/shared/services/institutions.service.ts
@@ -12,6 +12,7 @@ import {
Institution,
UserInstitutionGetResponse,
} from '@shared/models';
+import { InstitutionJsonApiModel } from '@shared/models/institutions/institution-json-api.model';
import { environment } from 'src/environments/environment';
@@ -53,6 +54,12 @@ export class InstitutionsService {
.pipe(map((response) => response.data.map((item) => UserInstitutionsMapper.fromResponse(item))));
}
+ getInstitutionById(institutionId: string): Observable {
+ return this.jsonApiService
+ .get(`${environment.apiUrl}/institutions/${institutionId}`)
+ .pipe(map((result) => result.data.attributes));
+ }
+
deleteUserInstitution(id: string, userId: string): Observable {
const payload = {
data: [{ id: id, type: 'institutions' }],
diff --git a/src/app/shared/services/search.service.ts b/src/app/shared/services/search.service.ts
index 04a41882..d7a20f32 100644
--- a/src/app/shared/services/search.service.ts
+++ b/src/app/shared/services/search.service.ts
@@ -2,9 +2,18 @@ import { map, Observable } from 'rxjs';
import { inject, Injectable } from '@angular/core';
+import { ApiData } from '@osf/core/models';
import { JsonApiService } from '@osf/core/services';
import { MapResources } from '@osf/features/search/mappers';
-import { IndexCardSearch, ResourcesData } from '@osf/features/search/models';
+import { IndexCardSearch, ResourceItem, ResourcesData } from '@osf/features/search/models';
+import {
+ AppliedFilter,
+ CombinedFilterMapper,
+ FilterOptionItem,
+ mapFilterOption,
+ RelatedPropertyPathItem,
+} from '@shared/mappers';
+import { FilterOptionsResponseJsonApi, SelectOption } from '@shared/models';
import { environment } from 'src/environments/environment';
@@ -12,7 +21,7 @@ import { environment } from 'src/environments/environment';
providedIn: 'root',
})
export class SearchService {
- #jsonApiService = inject(JsonApiService);
+ private readonly jsonApiService = inject(JsonApiService);
getResources(
filters: Record,
@@ -29,13 +38,22 @@ export class SearchService {
...filters,
};
- return this.#jsonApiService.get(`${environment.shareDomainUrl}/index-card-search`, params).pipe(
+ return this.jsonApiService.get(`${environment.shareDomainUrl}/index-card-search`, params).pipe(
map((response) => {
if (response?.included) {
+ const indexCardItems = response.included.filter(
+ (item): item is ApiData<{ resourceMetadata: ResourceItem }, null, null, null> => item.type === 'index-card'
+ );
+
+ const relatedPropertyPathItems = response.included.filter(
+ (item): item is RelatedPropertyPathItem => item.type === 'related-property-path'
+ );
+
+ const appliedFilters: AppliedFilter[] = response.data?.attributes?.cardSearchFilter || [];
+
return {
- resources: response?.included
- .filter((item) => item.type === 'index-card')
- .map((item) => MapResources(item.attributes.resourceMetadata)),
+ resources: indexCardItems.map((item) => MapResources(item.attributes.resourceMetadata)),
+ filters: CombinedFilterMapper(appliedFilters, relatedPropertyPathItems),
count: response.data.attributes.totalResultCount,
first: response.data?.relationships?.searchResultPage?.links?.first?.href,
next: response.data?.relationships?.searchResultPage?.links?.next?.href,
@@ -49,13 +67,22 @@ export class SearchService {
}
getResourcesByLink(link: string): Observable {
- return this.#jsonApiService.get(link).pipe(
+ return this.jsonApiService.get(link).pipe(
map((response) => {
if (response?.included) {
+ const indexCardItems = response.included.filter(
+ (item): item is ApiData<{ resourceMetadata: ResourceItem }, null, null, null> => item.type === 'index-card'
+ );
+
+ const relatedPropertyPathItems = response.included.filter(
+ (item): item is RelatedPropertyPathItem => item.type === 'related-property-path'
+ );
+
+ const appliedFilters: AppliedFilter[] = response.data?.attributes?.cardSearchFilter || [];
+
return {
- resources: response.included
- .filter((item) => item.type === 'index-card')
- .map((item) => MapResources(item.attributes.resourceMetadata)),
+ resources: indexCardItems.map((item) => MapResources(item.attributes.resourceMetadata)),
+ filters: CombinedFilterMapper(appliedFilters, relatedPropertyPathItems),
count: response.data.attributes.totalResultCount,
first: response.data?.relationships?.searchResultPage?.links?.first?.href,
next: response.data?.relationships?.searchResultPage?.links?.next?.href,
@@ -67,4 +94,27 @@ export class SearchService {
})
);
}
+
+ getFilterOptions(filterKey: string): Observable {
+ const params: Record = {
+ valueSearchPropertyPath: filterKey,
+ 'page[size]': '50',
+ };
+
+ return this.jsonApiService
+ .get(`${environment.shareDomainUrl}/index-card-search`, params)
+ .pipe(
+ map((response) => {
+ if (response?.included) {
+ const filterOptionItems = response.included.filter(
+ (item): item is FilterOptionItem => item.type === 'index-card' && !!item.attributes?.resourceMetadata
+ );
+
+ return filterOptionItems.map((item) => mapFilterOption(item));
+ }
+
+ return [];
+ })
+ );
+ }
}
diff --git a/src/app/shared/stores/index.ts b/src/app/shared/stores/index.ts
index 7b72ba9d..1ed4e7c7 100644
--- a/src/app/shared/stores/index.ts
+++ b/src/app/shared/stores/index.ts
@@ -1,3 +1,4 @@
export * from './addons';
export * from './institutions';
+export * from './institutions-search';
export * from './subjects';
diff --git a/src/app/shared/stores/institutions-search/index.ts b/src/app/shared/stores/institutions-search/index.ts
new file mode 100644
index 00000000..b7801022
--- /dev/null
+++ b/src/app/shared/stores/institutions-search/index.ts
@@ -0,0 +1,4 @@
+export * from './institutions-search.actions';
+export * from './institutions-search.model';
+export * from './institutions-search.selectors';
+export * from './institutions-search.state';
diff --git a/src/app/shared/stores/institutions-search/institutions-search.actions.ts b/src/app/shared/stores/institutions-search/institutions-search.actions.ts
new file mode 100644
index 00000000..6aeca964
--- /dev/null
+++ b/src/app/shared/stores/institutions-search/institutions-search.actions.ts
@@ -0,0 +1,52 @@
+import { ResourceTab } from '@shared/enums';
+
+export class FetchInstitutionById {
+ static readonly type = '[InstitutionsSearch] Fetch Institution By Id';
+
+ constructor(public institutionId: string) {}
+}
+
+export class FetchResources {
+ static readonly type = '[Institutions] Fetch Resources';
+}
+
+export class FetchResourcesByLink {
+ static readonly type = '[Institutions] Fetch Resources By Link';
+
+ constructor(public link: string) {}
+}
+
+export class UpdateResourceType {
+ static readonly type = '[Institutions] Update Resource Type';
+
+ constructor(public type: ResourceTab) {}
+}
+
+export class UpdateSortBy {
+ static readonly type = '[Institutions] Update Sort By';
+
+ constructor(public sortBy: string) {}
+}
+
+export class LoadFilterOptions {
+ static readonly type = '[InstitutionsSearch] Load Filter Options';
+ constructor(public filterKey: string) {}
+}
+
+export class UpdateFilterValue {
+ static readonly type = '[InstitutionsSearch] Update Filter Value';
+ constructor(
+ public filterKey: string,
+ public value: string | null
+ ) {}
+}
+
+export class SetFilterValues {
+ static readonly type = '[InstitutionsSearch] Set Filter Values';
+ constructor(public filterValues: Record) {}
+}
+
+export class LoadFilterOptionsAndSetValues {
+ static readonly type = '[InstitutionsSearch] Load Filter Options And Set Values';
+ constructor(public filterValues: Record) {}
+}
diff --git a/src/app/shared/stores/institutions-search/institutions-search.model.ts b/src/app/shared/stores/institutions-search/institutions-search.model.ts
new file mode 100644
index 00000000..3307861a
--- /dev/null
+++ b/src/app/shared/stores/institutions-search/institutions-search.model.ts
@@ -0,0 +1,18 @@
+import { ResourceTab } from '@shared/enums';
+import { AsyncStateModel, DiscoverableFilter, Institution, Resource, SelectOption } from '@shared/models';
+
+export interface InstitutionsSearchModel {
+ institution: AsyncStateModel;
+ resources: AsyncStateModel;
+ filters: DiscoverableFilter[];
+ filterValues: Record;
+ filterOptionsCache: Record;
+ providerIri: string;
+ resourcesCount: number;
+ searchText: string;
+ sortBy: string;
+ first: string;
+ next: string;
+ previous: string;
+ resourceType: ResourceTab;
+}
diff --git a/src/app/shared/stores/institutions-search/institutions-search.selectors.ts b/src/app/shared/stores/institutions-search/institutions-search.selectors.ts
new file mode 100644
index 00000000..ef8d8811
--- /dev/null
+++ b/src/app/shared/stores/institutions-search/institutions-search.selectors.ts
@@ -0,0 +1,83 @@
+import { Selector } from '@ngxs/store';
+
+import { DiscoverableFilter, Resource, SelectOption } from '@shared/models';
+
+import { InstitutionsSearchModel } from './institutions-search.model';
+import { InstitutionsSearchState } from './institutions-search.state';
+
+export class InstitutionsSearchSelectors {
+ @Selector([InstitutionsSearchState])
+ static getInstitution(state: InstitutionsSearchModel) {
+ return state.institution.data;
+ }
+
+ @Selector([InstitutionsSearchState])
+ static getInstitutionLoading(state: InstitutionsSearchModel) {
+ return state.institution.isLoading;
+ }
+
+ @Selector([InstitutionsSearchState])
+ static getResources(state: InstitutionsSearchModel): Resource[] {
+ return state.resources.data;
+ }
+
+ @Selector([InstitutionsSearchState])
+ static getResourcesLoading(state: InstitutionsSearchModel): boolean {
+ return state.resources.isLoading;
+ }
+
+ @Selector([InstitutionsSearchState])
+ static getFilters(state: InstitutionsSearchModel): DiscoverableFilter[] {
+ return state.filters;
+ }
+
+ @Selector([InstitutionsSearchState])
+ static getResourcesCount(state: InstitutionsSearchModel): number {
+ return state.resourcesCount;
+ }
+
+ @Selector([InstitutionsSearchState])
+ static getSearchText(state: InstitutionsSearchModel): string {
+ return state.searchText;
+ }
+
+ @Selector([InstitutionsSearchState])
+ static getSortBy(state: InstitutionsSearchModel): string {
+ return state.sortBy;
+ }
+
+ @Selector([InstitutionsSearchState])
+ static getIris(state: InstitutionsSearchModel): string {
+ return state.providerIri;
+ }
+
+ @Selector([InstitutionsSearchState])
+ static getFirst(state: InstitutionsSearchModel): string {
+ return state.first;
+ }
+
+ @Selector([InstitutionsSearchState])
+ static getNext(state: InstitutionsSearchModel): string {
+ return state.next;
+ }
+
+ @Selector([InstitutionsSearchState])
+ static getPrevious(state: InstitutionsSearchModel): string {
+ return state.previous;
+ }
+
+ @Selector([InstitutionsSearchState])
+ static getResourceType(state: InstitutionsSearchModel) {
+ return state.resourceType;
+ }
+
+ @Selector([InstitutionsSearchState])
+ static getFilterValues(state: InstitutionsSearchModel): Record {
+ return state.filterValues;
+ }
+
+ @Selector([InstitutionsSearchState])
+ static getFilterOptionsCache(state: InstitutionsSearchModel): Record {
+ return state.filterOptionsCache;
+ }
+}
diff --git a/src/app/shared/stores/institutions-search/institutions-search.state.ts b/src/app/shared/stores/institutions-search/institutions-search.state.ts
new file mode 100644
index 00000000..a5ae21ac
--- /dev/null
+++ b/src/app/shared/stores/institutions-search/institutions-search.state.ts
@@ -0,0 +1,242 @@
+import { Action, NgxsOnInit, State, StateContext, Store } from '@ngxs/store';
+import { patch } from '@ngxs/store/operators';
+
+import { BehaviorSubject, catchError, EMPTY, forkJoin, of, switchMap, tap, throwError } from 'rxjs';
+
+import { inject, Injectable } from '@angular/core';
+
+import { ResourcesData } from '@osf/features/search/models';
+import { GetResourcesRequestTypeEnum, ResourceTab } from '@shared/enums';
+import { Institution } from '@shared/models';
+import { InstitutionsService, SearchService } from '@shared/services';
+import { FetchResources, FetchResourcesByLink, InstitutionsSearchSelectors, UpdateResourceType } from '@shared/stores';
+import { getResourceTypes } from '@shared/utils';
+
+import {
+ FetchInstitutionById,
+ LoadFilterOptions,
+ LoadFilterOptionsAndSetValues,
+ SetFilterValues,
+ UpdateFilterValue,
+ UpdateSortBy,
+} from './institutions-search.actions';
+import { InstitutionsSearchModel } from './institutions-search.model';
+
+@State({
+ name: 'institutionsSearch',
+ defaults: {
+ institution: { data: {} as Institution, isLoading: false, error: null },
+ resources: { data: [], isLoading: false, error: null },
+ filters: [],
+ filterValues: {},
+ filterOptionsCache: {},
+ providerIri: '',
+ resourcesCount: 0,
+ searchText: '',
+ sortBy: '-relevance',
+ first: '',
+ next: '',
+ previous: '',
+ resourceType: ResourceTab.All,
+ },
+})
+@Injectable()
+export class InstitutionsSearchState implements NgxsOnInit {
+ private readonly institutionsService = inject(InstitutionsService);
+ private readonly searchService = inject(SearchService);
+ private readonly store = inject(Store);
+
+ private loadRequests = new BehaviorSubject<{ type: GetResourcesRequestTypeEnum; link?: string } | null>(null);
+ private filterOptionsRequests = new BehaviorSubject(null);
+
+ ngxsOnInit(ctx: StateContext): void {
+ this.setupLoadRequests(ctx);
+ this.setupFilterOptionsRequests(ctx);
+ }
+
+ private setupLoadRequests(ctx: StateContext) {
+ this.loadRequests
+ .pipe(
+ switchMap((query) => {
+ if (!query) return EMPTY;
+ return query.type === GetResourcesRequestTypeEnum.GetResources
+ ? this.loadResources(ctx)
+ : this.loadResourcesByLink(ctx, query.link);
+ })
+ )
+ .subscribe();
+ }
+
+ private loadResources(ctx: StateContext) {
+ const state = ctx.getState();
+ ctx.patchState({ resources: { ...state.resources, isLoading: true } });
+ const filtersParams: Record = {};
+ const searchText = this.store.selectSnapshot(InstitutionsSearchSelectors.getSearchText);
+ const sortBy = this.store.selectSnapshot(InstitutionsSearchSelectors.getSortBy);
+ const resourceTab = this.store.selectSnapshot(InstitutionsSearchSelectors.getResourceType);
+ const resourceTypes = getResourceTypes(resourceTab);
+
+ filtersParams['cardSearchFilter[affiliation][]'] = this.store.selectSnapshot(InstitutionsSearchSelectors.getIris);
+
+ Object.entries(state.filterValues).forEach(([key, value]) => {
+ if (value) filtersParams[`cardSearchFilter[${key}][]`] = value;
+ });
+
+ return this.searchService
+ .getResources(filtersParams, searchText, sortBy, resourceTypes)
+ .pipe(tap((response) => this.updateResourcesState(ctx, response)));
+ }
+
+ private loadResourcesByLink(ctx: StateContext, link?: string) {
+ if (!link) return EMPTY;
+ return this.searchService
+ .getResourcesByLink(link)
+ .pipe(tap((response) => this.updateResourcesState(ctx, response)));
+ }
+
+ private updateResourcesState(ctx: StateContext, response: ResourcesData) {
+ const state = ctx.getState();
+ const filtersWithCachedOptions = (response.filters || []).map((filter) => {
+ const cachedOptions = state.filterOptionsCache[filter.key];
+ return cachedOptions?.length ? { ...filter, options: cachedOptions, isLoaded: true } : filter;
+ });
+
+ ctx.patchState({
+ resources: { data: response.resources, isLoading: false, error: null },
+ filters: filtersWithCachedOptions,
+ resourcesCount: response.count,
+ first: response.first,
+ next: response.next,
+ previous: response.previous,
+ });
+ }
+
+ private setupFilterOptionsRequests(ctx: StateContext) {
+ this.filterOptionsRequests
+ .pipe(
+ switchMap((filterKey) => {
+ if (!filterKey) return EMPTY;
+ return this.handleFilterOptionLoad(ctx, filterKey);
+ })
+ )
+ .subscribe();
+ }
+
+ private handleFilterOptionLoad(ctx: StateContext, filterKey: string) {
+ const state = ctx.getState();
+ const cachedOptions = state.filterOptionsCache[filterKey];
+ if (cachedOptions?.length) {
+ const updatedFilters = state.filters.map((f) =>
+ f.key === filterKey ? { ...f, options: cachedOptions, isLoaded: true, isLoading: false } : f
+ );
+ ctx.patchState({ filters: updatedFilters });
+ return EMPTY;
+ }
+
+ const loadingFilters = state.filters.map((f) => (f.key === filterKey ? { ...f, isLoading: true } : f));
+ ctx.patchState({ filters: loadingFilters });
+
+ return this.searchService.getFilterOptions(filterKey).pipe(
+ tap((options) => {
+ const updatedCache = { ...ctx.getState().filterOptionsCache, [filterKey]: options };
+ const updatedFilters = ctx
+ .getState()
+ .filters.map((f) => (f.key === filterKey ? { ...f, options, isLoaded: true, isLoading: false } : f));
+ ctx.patchState({ filters: updatedFilters, filterOptionsCache: updatedCache });
+ })
+ );
+ }
+
+ @Action(FetchResources)
+ getResources() {
+ if (!this.store.selectSnapshot(InstitutionsSearchSelectors.getIris)) return;
+ this.loadRequests.next({ type: GetResourcesRequestTypeEnum.GetResources });
+ }
+
+ @Action(FetchResourcesByLink)
+ getResourcesByLink(_: StateContext, action: FetchResourcesByLink) {
+ this.loadRequests.next({ type: GetResourcesRequestTypeEnum.GetResourcesByLink, link: action.link });
+ }
+
+ @Action(LoadFilterOptions)
+ loadFilterOptions(_: StateContext, action: LoadFilterOptions) {
+ this.filterOptionsRequests.next(action.filterKey);
+ }
+
+ @Action(FetchInstitutionById)
+ fetchInstitutionById(ctx: StateContext, action: FetchInstitutionById) {
+ ctx.patchState({ institution: { data: {} as Institution, isLoading: true, error: null } });
+
+ return this.institutionsService.getInstitutionById(action.institutionId).pipe(
+ tap((response) => {
+ ctx.setState(
+ patch({
+ institution: patch({ data: response, error: null, isLoading: false }),
+ providerIri: response.iris.join(','),
+ })
+ );
+ this.loadRequests.next({ type: GetResourcesRequestTypeEnum.GetResources });
+ }),
+ catchError((error) => {
+ ctx.patchState({ institution: { ...ctx.getState().institution, isLoading: false, error } });
+ return throwError(() => error);
+ })
+ );
+ }
+
+ @Action(LoadFilterOptionsAndSetValues)
+ loadFilterOptionsAndSetValues(ctx: StateContext, action: LoadFilterOptionsAndSetValues) {
+ const filterKeys = Object.keys(action.filterValues).filter((key) => action.filterValues[key]);
+ if (!filterKeys.length) return;
+
+ const loadingFilters = ctx
+ .getState()
+ .filters.map((f) =>
+ filterKeys.includes(f.key) && !ctx.getState().filterOptionsCache[f.key]?.length ? { ...f, isLoading: true } : f
+ );
+ ctx.patchState({ filters: loadingFilters });
+
+ const observables = filterKeys.map((key) =>
+ this.searchService.getFilterOptions(key).pipe(
+ tap((options) => {
+ const updatedCache = { ...ctx.getState().filterOptionsCache, [key]: options };
+ const updatedFilters = ctx
+ .getState()
+ .filters.map((f) => (f.key === key ? { ...f, options, isLoaded: true, isLoading: false } : f));
+ ctx.patchState({ filters: updatedFilters, filterOptionsCache: updatedCache });
+ }),
+ catchError(() => of({ filterKey: key, options: [] }))
+ )
+ );
+
+ return forkJoin(observables).pipe(tap(() => ctx.patchState({ filterValues: action.filterValues })));
+ }
+
+ @Action(SetFilterValues)
+ setFilterValues(ctx: StateContext, action: SetFilterValues) {
+ ctx.patchState({ filterValues: action.filterValues });
+ }
+
+ @Action(UpdateFilterValue)
+ updateFilterValue(ctx: StateContext, action: UpdateFilterValue) {
+ if (action.filterKey === 'search') {
+ ctx.patchState({ searchText: action.value || '' });
+ this.loadRequests.next({ type: GetResourcesRequestTypeEnum.GetResources });
+ return;
+ }
+
+ const updatedFilterValues = { ...ctx.getState().filterValues, [action.filterKey]: action.value };
+ ctx.patchState({ filterValues: updatedFilterValues });
+ this.loadRequests.next({ type: GetResourcesRequestTypeEnum.GetResources });
+ }
+
+ @Action(UpdateResourceType)
+ updateResourceType(ctx: StateContext, action: UpdateResourceType) {
+ ctx.patchState({ resourceType: action.type });
+ }
+
+ @Action(UpdateSortBy)
+ updateSortBy(ctx: StateContext, action: UpdateSortBy) {
+ ctx.patchState({ sortBy: action.sortBy });
+ }
+}
diff --git a/src/app/shared/stores/institutions/institutions.actions.ts b/src/app/shared/stores/institutions/institutions.actions.ts
index 8bcc866b..f8f6c03a 100644
--- a/src/app/shared/stores/institutions/institutions.actions.ts
+++ b/src/app/shared/stores/institutions/institutions.actions.ts
@@ -1,9 +1,9 @@
-export class GetUserInstitutions {
- static readonly type = '[Institutions] Get User Institutions';
+export class FetchUserInstitutions {
+ static readonly type = '[Institutions] Fetch User Institutions';
}
export class FetchInstitutions {
- static readonly type = '[Institutions] Get';
+ static readonly type = '[Institutions] Fetch Institutions';
constructor(
public pageNumber: number,
diff --git a/src/app/shared/stores/institutions/institutions.state.ts b/src/app/shared/stores/institutions/institutions.state.ts
index 08b8c86f..5e9691d0 100644
--- a/src/app/shared/stores/institutions/institutions.state.ts
+++ b/src/app/shared/stores/institutions/institutions.state.ts
@@ -6,7 +6,7 @@ import { catchError, tap, throwError } from 'rxjs';
import { inject, Injectable } from '@angular/core';
import { InstitutionsService } from '@shared/services';
-import { FetchInstitutions, GetUserInstitutions, InstitutionsStateModel } from '@shared/stores';
+import { FetchInstitutions, FetchUserInstitutions, InstitutionsStateModel } from '@shared/stores';
@State({
name: 'institutions',
@@ -24,7 +24,7 @@ import { FetchInstitutions, GetUserInstitutions, InstitutionsStateModel } from '
export class InstitutionsState {
private readonly institutionsService = inject(InstitutionsService);
- @Action(GetUserInstitutions)
+ @Action(FetchUserInstitutions)
getUserInstitutions(ctx: StateContext) {
return this.institutionsService.getUserInstitutions().pipe(
tap((institutions) => {
@@ -36,7 +36,7 @@ export class InstitutionsState {
}
@Action(FetchInstitutions)
- getInstitutions(ctx: StateContext, action: FetchInstitutions) {
+ fetchInstitutions(ctx: StateContext, action: FetchInstitutions) {
ctx.patchState({
institutions: {
data: [],
diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json
index b28e7044..6727fdef 100644
--- a/src/assets/i18n/en.json
+++ b/src/assets/i18n/en.json
@@ -35,6 +35,17 @@
"projects": "Projects",
"files": "Files",
"users": "Users"
+ },
+ "filterPlaceholders": {
+ "affiliation": "Select institution",
+ "subject": "Select subject",
+ "funder": "Select funder",
+ "rights": "Select license",
+ "publisher": "Select provider",
+ "isPartOfCollection": "Select part of collection",
+ "dateCreated": "Select date",
+ "creator": "Creator name",
+ "resourceType": "Select resource type"
}
},
"labels": {
@@ -895,6 +906,9 @@
"placeholder": "Enter search term(s) here"
},
"filters": {
+ "sortBy": "Sort by",
+ "noFiltersAvailable": "No filters available",
+ "noOptionsAvailable": "No options available",
"programArea": {
"label": "Program Area",
"placeholder": "Select program areas",
diff --git a/src/assets/styles/_common.scss b/src/assets/styles/_common.scss
index 70e6783a..11a2f04e 100644
--- a/src/assets/styles/_common.scss
+++ b/src/assets/styles/_common.scss
@@ -103,3 +103,8 @@
.cursor-not-allowed {
cursor: not-allowed;
}
+
+// ------------------------- Object fit contain -------------------------
+.fit-contain {
+ object-fit: contain;
+}