From df17302c4186bcb7225621853c58b220a95c4f3b Mon Sep 17 00:00:00 2001 From: volodyayakubovskyy Date: Sat, 28 Jun 2025 22:21:03 +0300 Subject: [PATCH 01/10] feat(institutions): add reusable filter with routing handler --- .../institutions/institutions.component.html | 10 +- .../institutions/institutions.component.ts | 3 +- .../institutions/institutions.routes.ts | 9 + src/app/features/institutions/pages/index.ts | 1 + .../institutions-search.component.html | 6 + .../institutions-search.component.spec.ts | 22 + .../institutions-search.component.ts | 174 ++++++++ .../features/search/mappers/search.mapper.ts | 2 +- .../raw-models/index-card-search.model.ts | 11 +- .../search/models/resources-data.model.ts | 3 +- .../generic-filter.component.html | 22 + .../generic-filter.component.spec.ts | 394 ++++++++++++++++++ .../generic-filter.component.ts | 61 +++ src/app/shared/components/index.ts | 2 + .../reusable-filter.component.html | 28 ++ .../reusable-filter.component.spec.ts | 22 + .../reusable-filter.component.ts | 84 ++++ .../shared/constants/filter-placeholders.ts | 38 ++ src/app/shared/constants/index.ts | 1 + .../constants/resource-filters-defaults.ts | 7 + src/app/shared/directives/index.ts | 2 + .../directives/show-if-filter.directive.ts | 85 ++++ src/app/shared/enums/index.ts | 1 + .../shared/enums/reusable-filter-type.enum.ts | 28 ++ .../mappers/filters/filter-option.mapper.ts | 15 + src/app/shared/mappers/filters/index.ts | 2 + .../mappers/filters/reusable-filter.mapper.ts | 192 +++++++++ src/app/shared/models/index.ts | 1 + .../institution-json-api.model.ts | 10 + .../search/discaverable-filter.model.ts | 18 + .../models/search/filter-option.model.ts | 10 + .../search/filter-options-response.model.ts | 28 ++ src/app/shared/models/search/index.ts | 3 + .../shared/services/institutions.service.ts | 7 + src/app/shared/services/search.service.ts | 75 +++- src/app/shared/stores/index.ts | 1 + .../stores/institutions-search/index.ts | 4 + .../institutions-search.actions.ts | 46 ++ .../institutions-search.model.ts | 18 + .../institutions-search.selectors.ts | 83 ++++ .../institutions-search.state.ts | 230 ++++++++++ .../institutions/institutions.actions.ts | 2 +- .../stores/institutions/institutions.state.ts | 2 +- 43 files changed, 1740 insertions(+), 23 deletions(-) create mode 100644 src/app/features/institutions/pages/index.ts create mode 100644 src/app/features/institutions/pages/institutions-search/institutions-search.component.html create mode 100644 src/app/features/institutions/pages/institutions-search/institutions-search.component.spec.ts create mode 100644 src/app/features/institutions/pages/institutions-search/institutions-search.component.ts create mode 100644 src/app/shared/components/generic-filter/generic-filter.component.html create mode 100644 src/app/shared/components/generic-filter/generic-filter.component.spec.ts create mode 100644 src/app/shared/components/generic-filter/generic-filter.component.ts create mode 100644 src/app/shared/components/reusable-filter/reusable-filter.component.html create mode 100644 src/app/shared/components/reusable-filter/reusable-filter.component.spec.ts create mode 100644 src/app/shared/components/reusable-filter/reusable-filter.component.ts create mode 100644 src/app/shared/constants/filter-placeholders.ts create mode 100644 src/app/shared/directives/show-if-filter.directive.ts create mode 100644 src/app/shared/enums/reusable-filter-type.enum.ts create mode 100644 src/app/shared/mappers/filters/filter-option.mapper.ts create mode 100644 src/app/shared/mappers/filters/reusable-filter.mapper.ts create mode 100644 src/app/shared/models/institutions/institution-json-api.model.ts create mode 100644 src/app/shared/models/search/discaverable-filter.model.ts create mode 100644 src/app/shared/models/search/filter-option.model.ts create mode 100644 src/app/shared/models/search/filter-options-response.model.ts create mode 100644 src/app/shared/models/search/index.ts create mode 100644 src/app/shared/stores/institutions-search/index.ts create mode 100644 src/app/shared/stores/institutions-search/institutions-search.actions.ts create mode 100644 src/app/shared/stores/institutions-search/institutions-search.model.ts create mode 100644 src/app/shared/stores/institutions-search/institutions-search.selectors.ts create mode 100644 src/app/shared/stores/institutions-search/institutions-search.state.ts diff --git a/src/app/features/institutions/institutions.component.html b/src/app/features/institutions/institutions.component.html index 505a518b9..9806f9e69 100644 --- a/src/app/features/institutions/institutions.component.html +++ b/src/app/features/institutions/institutions.component.html @@ -16,11 +16,13 @@
@for (institution of institutions(); track $index) { - + }
diff --git a/src/app/features/institutions/institutions.component.ts b/src/app/features/institutions/institutions.component.ts index 2b5557c25..3e84bfaba 100644 --- a/src/app/features/institutions/institutions.component.ts +++ b/src/app/features/institutions/institutions.component.ts @@ -10,7 +10,7 @@ import { NgOptimizedImage } from '@angular/common'; import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, signal, untracked } 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 { @@ -32,6 +32,7 @@ import { FetchInstitutions, InstitutionsSelectors } from '@shared/stores'; NgOptimizedImage, CustomPaginatorComponent, LoadingSpinnerComponent, + RouterLink, ], templateUrl: './institutions.component.html', styleUrl: './institutions.component.scss', diff --git a/src/app/features/institutions/institutions.routes.ts b/src/app/features/institutions/institutions.routes.ts index 4d506a60d..bdb9449a5 100644 --- a/src/app/features/institutions/institutions.routes.ts +++ b/src/app/features/institutions/institutions.routes.ts @@ -1,10 +1,19 @@ +import { provideStates } from '@ngxs/store'; + import { Routes } from '@angular/router'; import { InstitutionsComponent } from '@osf/features/institutions/institutions.component'; +import { InstitutionsSearchComponent } from '@osf/features/institutions/pages/institutions-search/institutions-search.component'; +import { InstitutionsSearchState } from '@shared/stores'; 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 000000000..f189ec9bc --- /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 000000000..63d14aaad --- /dev/null +++ b/src/app/features/institutions/pages/institutions-search/institutions-search.component.html @@ -0,0 +1,6 @@ + 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 000000000..c70686419 --- /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 000000000..dad19709f --- /dev/null +++ b/src/app/features/institutions/pages/institutions-search/institutions-search.component.ts @@ -0,0 +1,174 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { Accordion, AccordionContent, AccordionHeader, AccordionPanel } from 'primeng/accordion'; +import { AutoCompleteModule } from 'primeng/autocomplete'; +import { Button } from 'primeng/button'; +import { Paginator } from 'primeng/paginator'; +import { Tabs, TabsModule } from 'primeng/tabs'; + +import { JsonPipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { SidenavComponent } from '@osf/core/components'; +import { BreadcrumbComponent } from '@osf/core/components/breadcrumb/breadcrumb.component'; +import { ReusableFilterComponent } from '@shared/components'; +import { ResourceTab } from '@shared/enums'; +import { DiscoverableFilter, Resource } from '@shared/models'; +import { + FetchInstitutionById, + FetchResources, + FetchResourcesByLink, + InstitutionsSearchSelectors, + LoadFilterOptions, + LoadFilterOptionsAndSetValues, + SetFilterValues, + UpdateFilterValue, + UpdateResourceType, +} from '@shared/stores'; + +@Component({ + selector: 'osf-institutions-search', + imports: [ + SidenavComponent, + BreadcrumbComponent, + ReusableFilterComponent, + AutoCompleteModule, + FormsModule, + Tabs, + TabsModule, + Accordion, + AccordionContent, + AccordionHeader, + AccordionPanel, + Button, + Paginator, + JsonPipe, + ], + templateUrl: './institutions-search.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class InstitutionsSearchComponent implements OnInit { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + + 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); + + private readonly actions = createDispatchMap({ + fetchInstitution: FetchInstitutionById, + updateResourceType: UpdateResourceType, + loadFilterOptions: LoadFilterOptions, + loadFilterOptionsAndSetValues: LoadFilterOptionsAndSetValues, + setFilterValues: SetFilterValues, + updateFilterValue: UpdateFilterValue, + fetchResourcesByLink: FetchResourcesByLink, + fetchResources: FetchResources, + }); + + readonly resourceTab = ResourceTab; + readonly resourceType = select(InstitutionsSearchSelectors.getResourceType); + + ngOnInit(): void { + this.restoreFiltersFromUrl(); + + const institutionId = this.route.snapshot.params['institution-id']; + if (institutionId) { + this.actions.fetchInstitution(institutionId); + } + } + + 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, + }); + } + + 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); + } + + onResourceTypeChange(type: ResourceTab): void { + this.actions.updateResourceType(type); + this.actions.fetchResources(); + } + + onNextPage(): void { + const next = select(InstitutionsSearchSelectors.getNext); + if (next()) { + this.actions.fetchResourcesByLink(next()); + } + } + + onPreviousPage(): void { + const previous = select(InstitutionsSearchSelectors.getPrevious); + if (previous()) { + this.actions.fetchResourcesByLink(previous()); + } + } + + navigateToResource(resource: Resource): void { + console.log(resource); + // todo: add in second step + } +} diff --git a/src/app/features/search/mappers/search.mapper.ts b/src/app/features/search/mappers/search.mapper.ts index f177c368e..5d365a1eb 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 fc37d736f..521332d13 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 3b708bc33..c9157d4b7 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/generic-filter/generic-filter.component.html b/src/app/shared/components/generic-filter/generic-filter.component.html new file mode 100644 index 000000000..a1e6ede6c --- /dev/null +++ b/src/app/shared/components/generic-filter/generic-filter.component.html @@ -0,0 +1,22 @@ + 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 000000000..14c1deed9 --- /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 000000000..8d3b0203a --- /dev/null +++ b/src/app/shared/components/generic-filter/generic-filter.component.ts @@ -0,0 +1,61 @@ +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(''); + editable = input(false); + 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 53aaeb0fc..e02471d47 100644 --- a/src/app/shared/components/index.ts +++ b/src/app/shared/components/index.ts @@ -10,6 +10,7 @@ export { FileMenuComponent } from './file-menu/file-menu.component'; export { FilesTreeComponent } from './files-tree/files-tree.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,6 +20,7 @@ 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 { SelectComponent } from './select/select.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 000000000..71ea1f803 --- /dev/null +++ b/src/app/shared/components/reusable-filter/reusable-filter.component.html @@ -0,0 +1,28 @@ +@if (hasFilters()) { +
+ + @for (filter of filters(); track filter.key) { + @if (shouldShowFilter(filter)) { + + {{ filter.label }} + + @if (filter.description) { +

{{ filter.description }}

+ } + + +
+
+ } + } +
+
+} 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 000000000..6258e2eba --- /dev/null +++ b/src/app/shared/components/reusable-filter/reusable-filter.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ReusableFilterComponent } from './reusable-filter.component'; + +describe('ReusableFilterComponentComponent', () => { + let component: ReusableFilterComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ReusableFilterComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ReusableFilterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); 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 000000000..6af0e79f8 --- /dev/null +++ b/src/app/shared/components/reusable-filter/reusable-filter.component.ts @@ -0,0 +1,84 @@ +import { Accordion, AccordionContent, AccordionHeader, AccordionPanel } from 'primeng/accordion'; +import { AutoCompleteModule } from 'primeng/autocomplete'; + +import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; +import { ReactiveFormsModule } from '@angular/forms'; + +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, + ], + templateUrl: './reusable-filter.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ReusableFilterComponent { + filters = input([]); + selectedValues = input>({}); + + loadFilterOptions = output<{ filterType: string; filter: DiscoverableFilter }>(); + filterValueChanged = output<{ filterType: string; value: string | null }>(); + + readonly FILTER_PLACEHOLDERS = FILTER_PLACEHOLDERS; + + readonly hasFilters = computed(() => { + const filterList = this.filters(); + return filterList.length > 0; + }); + + shouldShowFilter(filter: DiscoverableFilter): boolean { + return ( + (filter.resultCount && filter.resultCount > 0) || + (filter.options && filter.options.length > 0) || + filter.hasOptions === true + ); + } + + onAccordionToggle(filterKey: string | number | string[] | number[]): void { + if (filterKey) { + const selectedFilter = this.filters().find((value) => value.key === filterKey); + if (selectedFilter) { + this.loadFilterOptions.emit({ + filterType: filterKey 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]?.placeholder || ''; + } + + isFilterEditable(filterKey: string): boolean { + return this.FILTER_PLACEHOLDERS[filterKey]?.editable || false; + } +} diff --git a/src/app/shared/constants/filter-placeholders.ts b/src/app/shared/constants/filter-placeholders.ts new file mode 100644 index 000000000..cbbeac5a3 --- /dev/null +++ b/src/app/shared/constants/filter-placeholders.ts @@ -0,0 +1,38 @@ +export const FILTER_PLACEHOLDERS: Record = { + affiliation: { + placeholder: 'Select institution', + editable: false, + }, + subject: { + placeholder: 'Select subject', + editable: true, + }, + funder: { + placeholder: 'Select funder', + editable: true, + }, + rights: { + placeholder: 'Select license', + editable: false, + }, + publisher: { + placeholder: 'Select provider', + editable: false, + }, + isPartOfCollection: { + placeholder: 'Select part of collection', + editable: false, + }, + dateCreated: { + placeholder: 'Select date', + editable: false, + }, + creator: { + placeholder: 'Creator name', + editable: true, + }, + resourceType: { + placeholder: 'Select resource type', + editable: false, + }, +}; diff --git a/src/app/shared/constants/index.ts b/src/app/shared/constants/index.ts index fd9e0a180..bc3329a2c 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 './meetings-table.constants'; diff --git a/src/app/shared/constants/resource-filters-defaults.ts b/src/app/shared/constants/resource-filters-defaults.ts index c01ac7b5b..51ba9197b 100644 --- a/src/app/shared/constants/resource-filters-defaults.ts +++ b/src/app/shared/constants/resource-filters-defaults.ts @@ -47,3 +47,10 @@ export const resourceFiltersDefaults = { value: undefined, }, }; + +// this.loadRequests.next({ +// type: GetResourcesRequestTypeEnum.GetResources, +// filters: { +// institution: response.iris.join(','), +// }, +// }); diff --git a/src/app/shared/directives/index.ts b/src/app/shared/directives/index.ts index ceb698569..defdc9705 100644 --- a/src/app/shared/directives/index.ts +++ b/src/app/shared/directives/index.ts @@ -1 +1,3 @@ +export type { FilterTemplateContext } from './show-if-filter.directive'; +export { FilterItemDirective } from './show-if-filter.directive'; export { StopPropagationDirective } from './stop-propagation.directive'; diff --git a/src/app/shared/directives/show-if-filter.directive.ts b/src/app/shared/directives/show-if-filter.directive.ts new file mode 100644 index 000000000..86d9b5d7b --- /dev/null +++ b/src/app/shared/directives/show-if-filter.directive.ts @@ -0,0 +1,85 @@ +import { + Directive, + EmbeddedViewRef, + inject, + Input, + OnChanges, + SimpleChanges, + TemplateRef, + ViewContainerRef, +} from '@angular/core'; + +import { ReusableFilterType } from '@shared/enums'; +import { DiscoverableFilter } from '@shared/mappers/filters'; + +export interface FilterTemplateContext { + $implicit: DiscoverableFilter; + filter: DiscoverableFilter; + isVisible: boolean; +} + +@Directive({ + selector: '[osfFilterItem]', +}) +export class FilterItemDirective implements OnChanges { + private readonly templateRef = inject(TemplateRef); + private readonly viewContainer = inject(ViewContainerRef); + + private embeddedView: EmbeddedViewRef | null = null; + + @Input() osfFilterItem!: ReusableFilterType; + @Input() osfFilterItemFrom: DiscoverableFilter[] = []; + + ngOnChanges(changes: SimpleChanges): void { + if (changes['osfFilterItem'] || changes['osfFilterItemFrom']) { + this.updateView(); + } + } + + private updateView(): void { + const filter = this.getFilter(); + const shouldShow = this.shouldShowFilter(filter); + + if (shouldShow && filter) { + if (this.embeddedView) { + // Update existing view context + this.embeddedView.context.$implicit = filter; + this.embeddedView.context.filter = filter; + this.embeddedView.context.isVisible = true; + } else { + // Create new view + this.embeddedView = this.viewContainer.createEmbeddedView(this.templateRef, { + $implicit: filter, + filter: filter, + isVisible: true, + }); + } + } else { + // Clear view if should not show + if (this.embeddedView) { + this.viewContainer.clear(); + this.embeddedView = null; + } + } + } + + private getFilter(): DiscoverableFilter | undefined { + if (!this.osfFilterItemFrom?.length || !this.osfFilterItem) { + return undefined; + } + return this.osfFilterItemFrom.find((f) => f.key === this.osfFilterItem); + } + + private shouldShowFilter(filter: DiscoverableFilter | undefined): boolean { + if (!filter) { + return false; + } + + // Show filter if it has a result count > 0 or if it has options or hasOptions is true + return ( + (filter.resultCount && filter.resultCount > 0) || + (filter.options && filter.options.length > 0) || + filter.hasOptions === true + ); + } +} diff --git a/src/app/shared/enums/index.ts b/src/app/shared/enums/index.ts index b953b1783..5af34c16c 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 000000000..23c5bed54 --- /dev/null +++ b/src/app/shared/enums/reusable-filter-type.enum.ts @@ -0,0 +1,28 @@ +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', +} + +// Optional: Component mapping if needed for dynamic component loading +export const REUSABLE_FILTER_COMPONENTS: Record = { + [ReusableFilterType.AFFILIATION]: 'osf-reusable-institution-filter', + [ReusableFilterType.ACCESS_SERVICE]: 'osf-reusable-access-service-filter', + [ReusableFilterType.RESOURCE_TYPE]: 'osf-reusable-resource-type-filter', + [ReusableFilterType.SUBJECT]: 'osf-reusable-subject-filter', + [ReusableFilterType.FUNDER]: 'osf-reusable-funder-filter', + [ReusableFilterType.DATE_CREATED]: 'osf-reusable-date-created-filter', + [ReusableFilterType.CREATOR]: 'osf-reusable-creators-filter', + [ReusableFilterType.IS_PART_OF_COLLECTION]: 'osf-reusable-part-of-collection-filter', + [ReusableFilterType.PUBLISHER]: 'osf-reusable-provider-filter', + [ReusableFilterType.RIGHTS]: 'osf-reusable-license-filter', + [ReusableFilterType.RESOURCE_NATURE]: 'osf-reusable-resource-nature-filter', +}; 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 000000000..2ef5a1ab7 --- /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 7b93b15d9..e062214b6 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 000000000..2a08d29cf --- /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 c25c4edec..798a75c73 100644 --- a/src/app/shared/models/index.ts +++ b/src/app/shared/models/index.ts @@ -17,6 +17,7 @@ export * from './node-response.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 000000000..e5f78877a --- /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 000000000..a7ce461a3 --- /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 000000000..0d6171899 --- /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 000000000..5c83c6dc5 --- /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 FilterOptionsResponse { + 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 000000000..536356e76 --- /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 3331b95aa..56f70fadc 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 04a418826..cae2f558c 100644 --- a/src/app/shared/services/search.service.ts +++ b/src/app/shared/services/search.service.ts @@ -1,10 +1,19 @@ -import { map, Observable } from 'rxjs'; +import { map, Observable, tap } 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 { FilterOptionsResponse, 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, @@ -44,18 +62,28 @@ export class SearchService { } return {} as ResourcesData; - }) + }), + tap((res) => console.log(res)) ); } 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 +95,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 b94bb4458..11d2671db 100644 --- a/src/app/shared/stores/index.ts +++ b/src/app/shared/stores/index.ts @@ -1,2 +1,3 @@ export * from './addons'; export * from './institutions'; +export * from './institutions-search'; 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 000000000..b7801022a --- /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 000000000..1550a0190 --- /dev/null +++ b/src/app/shared/stores/institutions-search/institutions-search.actions.ts @@ -0,0 +1,46 @@ +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] Get Resources'; +} + +export class FetchResourcesByLink { + static readonly type = '[Institutions] Get Resources By Link'; + + constructor(public link: string) {} +} + +export class UpdateResourceType { + static readonly type = '[Institutions] Update Resource Type'; + + constructor(public type: ResourceTab) {} +} + +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 000000000..3307861a4 --- /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 000000000..ef8d8811c --- /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 000000000..2c65c90eb --- /dev/null +++ b/src/app/shared/stores/institutions-search/institutions-search.state.ts @@ -0,0 +1,230 @@ +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, +} 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) { + 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 }); + } +} diff --git a/src/app/shared/stores/institutions/institutions.actions.ts b/src/app/shared/stores/institutions/institutions.actions.ts index 8bcc866b0..528771a50 100644 --- a/src/app/shared/stores/institutions/institutions.actions.ts +++ b/src/app/shared/stores/institutions/institutions.actions.ts @@ -3,7 +3,7 @@ export class GetUserInstitutions { } export class FetchInstitutions { - static readonly type = '[Institutions] Get'; + static readonly type = '[Institutions] Fetch'; 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 08b8c86ff..703093df2 100644 --- a/src/app/shared/stores/institutions/institutions.state.ts +++ b/src/app/shared/stores/institutions/institutions.state.ts @@ -36,7 +36,7 @@ export class InstitutionsState { } @Action(FetchInstitutions) - getInstitutions(ctx: StateContext, action: FetchInstitutions) { + fetchInstitutions(ctx: StateContext, action: FetchInstitutions) { ctx.patchState({ institutions: { data: [], From c67317def9df7bbcca8e6ca944f5ac049108d80c Mon Sep 17 00:00:00 2001 From: volodyayakubovskyy Date: Mon, 30 Jun 2025 12:11:52 +0300 Subject: [PATCH 02/10] feat(institution-filter): add fixes by suggestions --- .../generic-filter.component.html | 3 +- .../generic-filter.component.ts | 2 +- .../reusable-filter.component.html | 3 +- .../reusable-filter.component.ts | 9 +- .../shared/constants/filter-placeholders.ts | 47 +++------- .../constants/resource-filters-defaults.ts | 7 -- src/app/shared/directives/index.ts | 2 - .../directives/show-if-filter.directive.ts | 85 ------------------- .../shared/enums/reusable-filter-type.enum.ts | 15 ---- .../search/filter-options-response.model.ts | 2 +- src/app/shared/services/search.service.ts | 4 +- src/assets/i18n/en.json | 11 +++ 12 files changed, 31 insertions(+), 159 deletions(-) delete mode 100644 src/app/shared/directives/show-if-filter.directive.ts diff --git a/src/app/shared/components/generic-filter/generic-filter.component.html b/src/app/shared/components/generic-filter/generic-filter.component.html index a1e6ede6c..3a9b61055 100644 --- a/src/app/shared/components/generic-filter/generic-filter.component.html +++ b/src/app/shared/components/generic-filter/generic-filter.component.html @@ -11,10 +11,9 @@ optionValue="value" [ngModel]="selectedValue()" [placeholder]="currentSelectedOption() ? currentSelectedOption()?.label : placeholder()" - [editable]="editable()" styleClass="w-full" appendTo="body" - [filter]="editable()" + filter (onChange)="onValueChange($event)" [showClear]="!!selectedValue()" > diff --git a/src/app/shared/components/generic-filter/generic-filter.component.ts b/src/app/shared/components/generic-filter/generic-filter.component.ts index 8d3b0203a..33d04a742 100644 --- a/src/app/shared/components/generic-filter/generic-filter.component.ts +++ b/src/app/shared/components/generic-filter/generic-filter.component.ts @@ -17,7 +17,7 @@ export class GenericFilterComponent { isLoading = input(false); selectedValue = input(null); placeholder = input(''); - editable = input(false); + // editable = input(false); filterType = input(''); valueChanged = output(); diff --git a/src/app/shared/components/reusable-filter/reusable-filter.component.html b/src/app/shared/components/reusable-filter/reusable-filter.component.html index 71ea1f803..92f99866d 100644 --- a/src/app/shared/components/reusable-filter/reusable-filter.component.html +++ b/src/app/shared/components/reusable-filter/reusable-filter.component.html @@ -14,8 +14,7 @@ [options]="getFilterOptions(filter)" [isLoading]="isFilterLoading(filter)" [selectedValue]="getSelectedValue(filter.key)" - [placeholder]="getFilterPlaceholder(filter.key)" - [editable]="isFilterEditable(filter.key)" + [placeholder]="getFilterPlaceholder(filter.key) | translate" [filterType]="filter.key" (valueChanged)="onFilterChanged(filter.key, $event)" /> diff --git a/src/app/shared/components/reusable-filter/reusable-filter.component.ts b/src/app/shared/components/reusable-filter/reusable-filter.component.ts index 6af0e79f8..16718a8fc 100644 --- a/src/app/shared/components/reusable-filter/reusable-filter.component.ts +++ b/src/app/shared/components/reusable-filter/reusable-filter.component.ts @@ -1,3 +1,5 @@ +import { TranslatePipe } from '@ngx-translate/core'; + import { Accordion, AccordionContent, AccordionHeader, AccordionPanel } from 'primeng/accordion'; import { AutoCompleteModule } from 'primeng/autocomplete'; @@ -20,6 +22,7 @@ import { GenericFilterComponent } from '../generic-filter/generic-filter.compone AutoCompleteModule, ReactiveFormsModule, GenericFilterComponent, + TranslatePipe, ], templateUrl: './reusable-filter.component.html', changeDetection: ChangeDetectionStrategy.OnPush, @@ -75,10 +78,6 @@ export class ReusableFilterComponent { } getFilterPlaceholder(filterKey: string): string { - return this.FILTER_PLACEHOLDERS[filterKey]?.placeholder || ''; - } - - isFilterEditable(filterKey: string): boolean { - return this.FILTER_PLACEHOLDERS[filterKey]?.editable || false; + return this.FILTER_PLACEHOLDERS[filterKey] || ''; } } diff --git a/src/app/shared/constants/filter-placeholders.ts b/src/app/shared/constants/filter-placeholders.ts index cbbeac5a3..de0ec67e5 100644 --- a/src/app/shared/constants/filter-placeholders.ts +++ b/src/app/shared/constants/filter-placeholders.ts @@ -1,38 +1,11 @@ -export const FILTER_PLACEHOLDERS: Record = { - affiliation: { - placeholder: 'Select institution', - editable: false, - }, - subject: { - placeholder: 'Select subject', - editable: true, - }, - funder: { - placeholder: 'Select funder', - editable: true, - }, - rights: { - placeholder: 'Select license', - editable: false, - }, - publisher: { - placeholder: 'Select provider', - editable: false, - }, - isPartOfCollection: { - placeholder: 'Select part of collection', - editable: false, - }, - dateCreated: { - placeholder: 'Select date', - editable: false, - }, - creator: { - placeholder: 'Creator name', - editable: true, - }, - resourceType: { - placeholder: 'Select resource type', - editable: false, - }, +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/resource-filters-defaults.ts b/src/app/shared/constants/resource-filters-defaults.ts index 51ba9197b..c01ac7b5b 100644 --- a/src/app/shared/constants/resource-filters-defaults.ts +++ b/src/app/shared/constants/resource-filters-defaults.ts @@ -47,10 +47,3 @@ export const resourceFiltersDefaults = { value: undefined, }, }; - -// this.loadRequests.next({ -// type: GetResourcesRequestTypeEnum.GetResources, -// filters: { -// institution: response.iris.join(','), -// }, -// }); diff --git a/src/app/shared/directives/index.ts b/src/app/shared/directives/index.ts index defdc9705..ceb698569 100644 --- a/src/app/shared/directives/index.ts +++ b/src/app/shared/directives/index.ts @@ -1,3 +1 @@ -export type { FilterTemplateContext } from './show-if-filter.directive'; -export { FilterItemDirective } from './show-if-filter.directive'; export { StopPropagationDirective } from './stop-propagation.directive'; diff --git a/src/app/shared/directives/show-if-filter.directive.ts b/src/app/shared/directives/show-if-filter.directive.ts deleted file mode 100644 index 86d9b5d7b..000000000 --- a/src/app/shared/directives/show-if-filter.directive.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { - Directive, - EmbeddedViewRef, - inject, - Input, - OnChanges, - SimpleChanges, - TemplateRef, - ViewContainerRef, -} from '@angular/core'; - -import { ReusableFilterType } from '@shared/enums'; -import { DiscoverableFilter } from '@shared/mappers/filters'; - -export interface FilterTemplateContext { - $implicit: DiscoverableFilter; - filter: DiscoverableFilter; - isVisible: boolean; -} - -@Directive({ - selector: '[osfFilterItem]', -}) -export class FilterItemDirective implements OnChanges { - private readonly templateRef = inject(TemplateRef); - private readonly viewContainer = inject(ViewContainerRef); - - private embeddedView: EmbeddedViewRef | null = null; - - @Input() osfFilterItem!: ReusableFilterType; - @Input() osfFilterItemFrom: DiscoverableFilter[] = []; - - ngOnChanges(changes: SimpleChanges): void { - if (changes['osfFilterItem'] || changes['osfFilterItemFrom']) { - this.updateView(); - } - } - - private updateView(): void { - const filter = this.getFilter(); - const shouldShow = this.shouldShowFilter(filter); - - if (shouldShow && filter) { - if (this.embeddedView) { - // Update existing view context - this.embeddedView.context.$implicit = filter; - this.embeddedView.context.filter = filter; - this.embeddedView.context.isVisible = true; - } else { - // Create new view - this.embeddedView = this.viewContainer.createEmbeddedView(this.templateRef, { - $implicit: filter, - filter: filter, - isVisible: true, - }); - } - } else { - // Clear view if should not show - if (this.embeddedView) { - this.viewContainer.clear(); - this.embeddedView = null; - } - } - } - - private getFilter(): DiscoverableFilter | undefined { - if (!this.osfFilterItemFrom?.length || !this.osfFilterItem) { - return undefined; - } - return this.osfFilterItemFrom.find((f) => f.key === this.osfFilterItem); - } - - private shouldShowFilter(filter: DiscoverableFilter | undefined): boolean { - if (!filter) { - return false; - } - - // Show filter if it has a result count > 0 or if it has options or hasOptions is true - return ( - (filter.resultCount && filter.resultCount > 0) || - (filter.options && filter.options.length > 0) || - filter.hasOptions === true - ); - } -} diff --git a/src/app/shared/enums/reusable-filter-type.enum.ts b/src/app/shared/enums/reusable-filter-type.enum.ts index 23c5bed54..7c1dbba88 100644 --- a/src/app/shared/enums/reusable-filter-type.enum.ts +++ b/src/app/shared/enums/reusable-filter-type.enum.ts @@ -11,18 +11,3 @@ export enum ReusableFilterType { RIGHTS = 'rights', RESOURCE_NATURE = 'resourceNature', } - -// Optional: Component mapping if needed for dynamic component loading -export const REUSABLE_FILTER_COMPONENTS: Record = { - [ReusableFilterType.AFFILIATION]: 'osf-reusable-institution-filter', - [ReusableFilterType.ACCESS_SERVICE]: 'osf-reusable-access-service-filter', - [ReusableFilterType.RESOURCE_TYPE]: 'osf-reusable-resource-type-filter', - [ReusableFilterType.SUBJECT]: 'osf-reusable-subject-filter', - [ReusableFilterType.FUNDER]: 'osf-reusable-funder-filter', - [ReusableFilterType.DATE_CREATED]: 'osf-reusable-date-created-filter', - [ReusableFilterType.CREATOR]: 'osf-reusable-creators-filter', - [ReusableFilterType.IS_PART_OF_COLLECTION]: 'osf-reusable-part-of-collection-filter', - [ReusableFilterType.PUBLISHER]: 'osf-reusable-provider-filter', - [ReusableFilterType.RIGHTS]: 'osf-reusable-license-filter', - [ReusableFilterType.RESOURCE_NATURE]: 'osf-reusable-resource-nature-filter', -}; diff --git a/src/app/shared/models/search/filter-options-response.model.ts b/src/app/shared/models/search/filter-options-response.model.ts index 5c83c6dc5..b95ff613e 100644 --- a/src/app/shared/models/search/filter-options-response.model.ts +++ b/src/app/shared/models/search/filter-options-response.model.ts @@ -9,7 +9,7 @@ export interface FilterOptionsResponseData { relationships?: Record; } -export interface FilterOptionsResponse { +export interface FilterOptionsResponseJsonApi { data: FilterOptionsResponseData; included?: FilterOptionItem[]; links?: { diff --git a/src/app/shared/services/search.service.ts b/src/app/shared/services/search.service.ts index cae2f558c..eea1b70db 100644 --- a/src/app/shared/services/search.service.ts +++ b/src/app/shared/services/search.service.ts @@ -13,7 +13,7 @@ import { mapFilterOption, RelatedPropertyPathItem, } from '@shared/mappers'; -import { FilterOptionsResponse, SelectOption } from '@shared/models'; +import { FilterOptionsResponseJsonApi, SelectOption } from '@shared/models'; import { environment } from 'src/environments/environment'; @@ -103,7 +103,7 @@ export class SearchService { }; return this.jsonApiService - .get(`${environment.shareDomainUrl}/index-card-search`, params) + .get(`${environment.shareDomainUrl}/index-card-search`, params) .pipe( map((response) => { if (response?.included) { diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 761709881..d42dd7d3b 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": { From 8fb85d2b2c4cd8df6366d1f3760e9c2dc71ad34e Mon Sep 17 00:00:00 2001 From: volodyayakubovskyy Date: Mon, 30 Jun 2025 18:23:32 +0300 Subject: [PATCH 03/10] feat(institution): add search page --- .../institutions-search.component.html | 69 ++- .../institutions-search.component.ts | 217 ++++++--- .../filter-chips/filter-chips.component.html | 16 + .../filter-chips.component.spec.ts | 243 ++++++++++ .../filter-chips/filter-chips.component.ts | 50 ++ src/app/shared/components/index.ts | 2 + .../reusable-filter.component.html | 49 +- .../reusable-filter.component.spec.ts | 453 +++++++++++++++++- .../reusable-filter.component.ts | 84 +++- .../search-help-tutorial.component.ts | 1 + .../search-input/search-input.component.ts | 1 + .../search-results-container.component.html | 128 +++++ .../search-results-container.component.scss | 85 ++++ .../search-results-container.component.ts | 85 ++++ .../institutions-search.actions.ts | 6 + .../institutions-search.state.ts | 6 + 16 files changed, 1405 insertions(+), 90 deletions(-) create mode 100644 src/app/shared/components/filter-chips/filter-chips.component.html create mode 100644 src/app/shared/components/filter-chips/filter-chips.component.spec.ts create mode 100644 src/app/shared/components/filter-chips/filter-chips.component.ts create mode 100644 src/app/shared/components/search-results-container/search-results-container.component.html create mode 100644 src/app/shared/components/search-results-container/search-results-container.component.scss create mode 100644 src/app/shared/components/search-results-container/search-results-container.component.ts 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 index 63d14aaad..f25c63b9d 100644 --- a/src/app/features/institutions/pages/institutions-search/institutions-search.component.html +++ b/src/app/features/institutions/pages/institutions-search/institutions-search.component.html @@ -1,6 +1,63 @@ - +
+
+ +
+ +
+ + + @for (item of resourceTabOptions; track $index) { + {{ item.label | translate }} + } + + + +
+ +
+ +
+ +
+ +
+
+ + +
+
+
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 index dad19709f..3741c3742 100644 --- a/src/app/features/institutions/pages/institutions-search/institutions-search.component.ts +++ b/src/app/features/institutions/pages/institutions-search/institutions-search.component.ts @@ -1,21 +1,20 @@ import { createDispatchMap, select } from '@ngxs/store'; -import { Accordion, AccordionContent, AccordionHeader, AccordionPanel } from 'primeng/accordion'; +import { TranslatePipe } from '@ngx-translate/core'; + import { AutoCompleteModule } from 'primeng/autocomplete'; -import { Button } from 'primeng/button'; -import { Paginator } from 'primeng/paginator'; import { Tabs, TabsModule } from 'primeng/tabs'; -import { JsonPipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core'; -import { FormsModule } from '@angular/forms'; +import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core'; +import { FormControl, FormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; -import { SidenavComponent } from '@osf/core/components'; -import { BreadcrumbComponent } from '@osf/core/components/breadcrumb/breadcrumb.component'; -import { ReusableFilterComponent } from '@shared/components'; +import { 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, Resource } from '@shared/models'; +import { DiscoverableFilter } from '@shared/models'; import { FetchInstitutionById, FetchResources, @@ -26,28 +25,26 @@ import { SetFilterValues, UpdateFilterValue, UpdateResourceType, + UpdateSortBy, } from '@shared/stores'; @Component({ selector: 'osf-institutions-search', imports: [ - SidenavComponent, - BreadcrumbComponent, ReusableFilterComponent, + SearchResultsContainerComponent, + FilterChipsComponent, AutoCompleteModule, FormsModule, Tabs, TabsModule, - Accordion, - AccordionContent, - AccordionHeader, - AccordionPanel, - Button, - Paginator, - JsonPipe, + SearchHelpTutorialComponent, + SearchInputComponent, + TranslatePipe, ], templateUrl: './institutions-search.component.html', changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, }) export class InstitutionsSearchComponent implements OnInit { private readonly route = inject(ActivatedRoute); @@ -60,10 +57,15 @@ export class InstitutionsSearchComponent implements OnInit { 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, @@ -71,12 +73,54 @@ export class InstitutionsSearchComponent implements OnInit { 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(false); + 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(); const institutionId = this.route.snapshot.params['institution-id']; if (institutionId) { @@ -84,6 +128,82 @@ export class InstitutionsSearchComponent implements OnInit { } } + 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(); + } + + onTabChanged(tab: ResourceTab): void { + this.actions.updateResourceType(tab); + 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.updateUrlWithFilters({}); + + this.actions.fetchResources(); + } + private restoreFiltersFromUrl(): void { const queryParams = this.route.snapshot.queryParams; const filterValues: Record = {}; @@ -126,49 +246,32 @@ export class InstitutionsSearchComponent implements OnInit { }); } - 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); + private updateUrlWithTab(tab: ResourceTab): void { + const queryParams: Record = { ...this.route.snapshot.queryParams }; - const currentFilters = this.selectedValues(); - const updatedFilters = { - ...currentFilters, - [event.filterType]: event.value, - }; + if (tab !== ResourceTab.All) { + queryParams['tab'] = this.tabUrlMap.get(tab) || 'all'; + } else { + delete queryParams['tab']; + } - Object.keys(updatedFilters).forEach((key) => { - if (!updatedFilters[key]) { - delete updatedFilters[key]; - } + this.router.navigate([], { + relativeTo: this.route, + queryParams, + queryParamsHandling: 'replace', + replaceUrl: true, }); - - this.updateUrlWithFilters(updatedFilters); - } - - onResourceTypeChange(type: ResourceTab): void { - this.actions.updateResourceType(type); - this.actions.fetchResources(); - } - - onNextPage(): void { - const next = select(InstitutionsSearchSelectors.getNext); - if (next()) { - this.actions.fetchResourcesByLink(next()); - } } - onPreviousPage(): void { - const previous = select(InstitutionsSearchSelectors.getPrevious); - if (previous()) { - this.actions.fetchResourcesByLink(previous()); + 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); + } } } - - navigateToResource(resource: Resource): void { - console.log(resource); - // todo: add in second step - } } 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 000000000..3dcbc5049 --- /dev/null +++ b/src/app/shared/components/filter-chips/filter-chips.component.html @@ -0,0 +1,16 @@ +@if (chips().length > 0) { +
+ @for (chip of chips(); track chip.key + chip.value) { + + } + + @if (chips().length > 1) { + + } +
+} 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 000000000..6869018f4 --- /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 000000000..9058238f7 --- /dev/null +++ b/src/app/shared/components/filter-chips/filter-chips.component.ts @@ -0,0 +1,50 @@ +import { Chip } from 'primeng/chip'; + +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; + +@Component({ + selector: 'osf-filter-chips', + standalone: true, + 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(([_, 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/index.ts b/src/app/shared/components/index.ts index e02471d47..e6ae10d9f 100644 --- a/src/app/shared/components/index.ts +++ b/src/app/shared/components/index.ts @@ -8,6 +8,7 @@ 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'; @@ -23,6 +24,7 @@ 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 index 71ea1f803..d93acb55a 100644 --- a/src/app/shared/components/reusable-filter/reusable-filter.component.html +++ b/src/app/shared/components/reusable-filter/reusable-filter.component.html @@ -1,15 +1,32 @@ -@if (hasFilters()) { -
+@if (isLoading()) { +
+ +
+} @else if (hasVisibleFilters()) { +
- @for (filter of filters(); track filter.key) { - @if (shouldShowFilter(filter)) { - - {{ filter.label }} - - @if (filter.description) { -

{{ filter.description }}

- } + @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 { +

No options available

+ } +
+
}
+} @else if (showEmptyState()) { +
+

No filters available

+
} 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 index 6258e2eba..1347e7bb7 100644 --- a/src/app/shared/components/reusable-filter/reusable-filter.component.spec.ts +++ b/src/app/shared/components/reusable-filter/reusable-filter.component.spec.ts @@ -1,22 +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('ReusableFilterComponentComponent', () => { +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], + imports: [ReusableFilterComponent, NoopAnimationsModule], }).compileComponents(); fixture = TestBed.createComponent(ReusableFilterComponent); component = fixture.componentInstance; - fixture.detectChanges(); + 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 index 6af0e79f8..f664d58b5 100644 --- a/src/app/shared/components/reusable-filter/reusable-filter.component.ts +++ b/src/app/shared/components/reusable-filter/reusable-filter.component.ts @@ -1,9 +1,10 @@ import { Accordion, AccordionContent, AccordionHeader, AccordionPanel } from 'primeng/accordion'; import { AutoCompleteModule } from 'primeng/autocomplete'; -import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; +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'; @@ -20,38 +21,73 @@ import { GenericFilterComponent } from '../generic-filter/generic-filter.compone AutoCompleteModule, ReactiveFormsModule, GenericFilterComponent, + LoadingSpinnerComponent, ], templateUrl: './reusable-filter.component.html', changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, }) 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.length > 0; + 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 { - return ( + 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 === true + (filter.options && filter.options.length > 0) || + filter.hasOptions || + (filter.selectedValues && filter.selectedValues.length > 0) ); } onAccordionToggle(filterKey: string | number | string[] | number[]): void { - if (filterKey) { - const selectedFilter = this.filters().find((value) => value.key === filterKey); - if (selectedFilter) { + 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: filterKey as ReusableFilterType, + filterType: key as ReusableFilterType, filter: selectedFilter, }); } @@ -75,10 +111,36 @@ export class ReusableFilterComponent { } getFilterPlaceholder(filterKey: string): string { - return this.FILTER_PLACEHOLDERS[filterKey]?.placeholder || ''; + return this.FILTER_PLACEHOLDERS[filterKey]?.placeholder || 'Search...'; } isFilterEditable(filterKey: string): boolean { - return this.FILTER_PLACEHOLDERS[filterKey]?.editable || false; + return this.FILTER_PLACEHOLDERS[filterKey]?.editable ?? true; + } + + 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 || 'Learn more'; + } + + getFilterLabel(filter: DiscoverableFilter): string { + return filter.label || filter.key || 'Filter'; + } + + hasFilterContent(filter: DiscoverableFilter): boolean { + return !!( + filter.description || + filter.helpLink || + filter.resultCount || + filter.options?.length || + filter.hasOptions + ); } } diff --git a/src/app/shared/components/search-help-tutorial/search-help-tutorial.component.ts b/src/app/shared/components/search-help-tutorial/search-help-tutorial.component.ts index 68946b491..bb60daab6 100644 --- a/src/app/shared/components/search-help-tutorial/search-help-tutorial.component.ts +++ b/src/app/shared/components/search-help-tutorial/search-help-tutorial.component.ts @@ -14,6 +14,7 @@ import { TutorialStep } from '@osf/shared/models'; templateUrl: './search-help-tutorial.component.html', styleUrl: './search-help-tutorial.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, }) export class SearchHelpTutorialComponent { currentStep = model(0); diff --git a/src/app/shared/components/search-input/search-input.component.ts b/src/app/shared/components/search-input/search-input.component.ts index 92c5427bf..8789409ac 100644 --- a/src/app/shared/components/search-input/search-input.component.ts +++ b/src/app/shared/components/search-input/search-input.component.ts @@ -12,6 +12,7 @@ import { IconComponent } from '../icon/icon.component'; templateUrl: './search-input.component.html', styleUrl: './search-input.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, }) export class SearchInputComponent { control = input(new FormControl('')); 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 000000000..7006079bf --- /dev/null +++ b/src/app/shared/components/search-results-container/search-results-container.component.html @@ -0,0 +1,128 @@ +
+
+ + + @if (searchCount() > 10000) { +

10 000+ results

+ } @else if (searchCount() > 0) { +

{{ searchCount() }} results

+ } @else { +

0 results

+ } +
+ +
+ @if (isWeb()) { +

Sort by:

+ + + } @else { + @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 000000000..d96573678 --- /dev/null +++ b/src/app/shared/components/search-results-container/search-results-container.component.scss @@ -0,0 +1,85 @@ +.sorting-container { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.filter-full-size { + width: 100%; + margin-bottom: 1rem; +} + +.sort-card { + padding: 0.75rem 1rem; + border: 1px solid var(--grey-2); + border-radius: 8px; + background-color: white; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background-color: var(--grey-3); + border-color: var(--pr-blue-1); + } + + &.card-selected { + background-color: var(--pr-blue-1); + color: white; + border-color: var(--pr-blue-1); + } +} + +.filters-resources-web { + @media (max-width: 576px) { + flex-direction: column; + gap: 1rem; + } +} + +.resources-container { + flex: 1; + width: 100%; +} + +.resources-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.custom-dark-hover { + transition: opacity 0.2s ease; + + &:hover { + opacity: 0.7; + } +} + +.no-border-dropdown { + ::ng-deep { + .p-select { + border: none; + box-shadow: none; + background: transparent; + + &:focus { + box-shadow: none; + border: none; + } + } + + .p-select-label { + font-weight: bold; + } + } +} + +@media (max-width: 576px) { + .sorting-container { + justify-content: flex-end; + } + + .filters-resources-web { + flex-direction: column; + } +} 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 000000000..ddcd239a2 --- /dev/null +++ b/src/app/shared/components/search-results-container/search-results-container.component.ts @@ -0,0 +1,85 @@ +import { Button } from 'primeng/button'; +import { DataView } from 'primeng/dataview'; +import { Select } from 'primeng/select'; + +import { NgOptimizedImage } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, inject, input, output } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { FormsModule } from '@angular/forms'; + +import { SEARCH_TAB_OPTIONS, searchSortingOptions } from '@shared/constants'; +import { ResourceTab } from '@shared/enums'; +import { Resource } from '@shared/models'; +import { IS_WEB, IS_XSMALL } from '@shared/utils'; + +import { ResourceCardComponent } from '../resource-card/resource-card.component'; + +@Component({ + selector: 'osf-search-results-container', + imports: [FormsModule, NgOptimizedImage, Button, DataView, Select, ResourceCardComponent], + templateUrl: './search-results-container.component.html', + styleUrl: './search-results-container.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, +}) +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 isMobile = toSignal(inject(IS_XSMALL)); + protected readonly isWeb = toSignal(inject(IS_WEB)); + + 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); + } + + 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/stores/institutions-search/institutions-search.actions.ts b/src/app/shared/stores/institutions-search/institutions-search.actions.ts index 1550a0190..7e3706845 100644 --- a/src/app/shared/stores/institutions-search/institutions-search.actions.ts +++ b/src/app/shared/stores/institutions-search/institutions-search.actions.ts @@ -22,6 +22,12 @@ export class UpdateResourceType { 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) {} diff --git a/src/app/shared/stores/institutions-search/institutions-search.state.ts b/src/app/shared/stores/institutions-search/institutions-search.state.ts index 2c65c90eb..46ad6adca 100644 --- a/src/app/shared/stores/institutions-search/institutions-search.state.ts +++ b/src/app/shared/stores/institutions-search/institutions-search.state.ts @@ -18,6 +18,7 @@ import { LoadFilterOptionsAndSetValues, SetFilterValues, UpdateFilterValue, + UpdateSortBy, } from './institutions-search.actions'; import { InstitutionsSearchModel } from './institutions-search.model'; @@ -227,4 +228,9 @@ export class InstitutionsSearchState implements NgxsOnInit { updateResourceType(ctx: StateContext, action: UpdateResourceType) { ctx.patchState({ resourceType: action.type }); } + + @Action(UpdateSortBy) + updateSortBy(ctx: StateContext, action: UpdateSortBy) { + ctx.patchState({ sortBy: action.sortBy }); + } } From 0edbbd9d17cc6cca765d6105c24aa7233258455a Mon Sep 17 00:00:00 2001 From: volodyayakubovskyy Date: Mon, 30 Jun 2025 19:39:38 +0300 Subject: [PATCH 04/10] feat(institution): add search page --- .../institutions-search.component.html | 116 +++++++++--------- .../filter-chips/filter-chips.component.html | 4 - .../filter-chips/filter-chips.component.ts | 2 +- .../reusable-filter.component.html | 4 +- .../reusable-filter.component.ts | 4 - .../search-results-container.component.html | 22 ++-- .../search-results-container.component.scss | 4 + .../search-results-container.component.ts | 4 +- src/assets/i18n/en.json | 3 + 9 files changed, 84 insertions(+), 79 deletions(-) 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 index f25c63b9d..98728ede1 100644 --- a/src/app/features/institutions/pages/institutions-search/institutions-search.component.html +++ b/src/app/features/institutions/pages/institutions-search/institutions-search.component.html @@ -1,63 +1,65 @@ -
-
- -
+
+
+
+ +
-
- - - @for (item of resourceTabOptions; track $index) { - {{ item.label | translate }} - } - - +
+ + + @for (item of resourceTabOptions; track $index) { + {{ item.label | translate }} + } + + -
- -
- -
+
+ +
+ +
-
- -
-
+
+ +
+ - + +
-
+
diff --git a/src/app/shared/components/filter-chips/filter-chips.component.html b/src/app/shared/components/filter-chips/filter-chips.component.html index 3dcbc5049..5533f3dff 100644 --- a/src/app/shared/components/filter-chips/filter-chips.component.html +++ b/src/app/shared/components/filter-chips/filter-chips.component.html @@ -8,9 +8,5 @@ (onRemove)="removeFilter(chip.key)" > } - - @if (chips().length > 1) { - - }
} diff --git a/src/app/shared/components/filter-chips/filter-chips.component.ts b/src/app/shared/components/filter-chips/filter-chips.component.ts index 9058238f7..79e1dda52 100644 --- a/src/app/shared/components/filter-chips/filter-chips.component.ts +++ b/src/app/shared/components/filter-chips/filter-chips.component.ts @@ -24,7 +24,7 @@ export class FilterChipsComponent { const options = this.filterOptions(); return Object.entries(values) - .filter(([_, value]) => value !== null && value !== '') + .filter(([key, value]) => value !== null && value !== '') .map(([key, value]) => { const filterLabel = labels[key] || key; const filterOptionsList = options[key] || []; diff --git a/src/app/shared/components/reusable-filter/reusable-filter.component.html b/src/app/shared/components/reusable-filter/reusable-filter.component.html index d49724d25..98cc026fc 100644 --- a/src/app/shared/components/reusable-filter/reusable-filter.component.html +++ b/src/app/shared/components/reusable-filter/reusable-filter.component.html @@ -36,7 +36,7 @@ (valueChanged)="onFilterChanged(filter.key, $event)" /> } @else { -

No options available

+

{{ 'collections.filters.noOptionsAvailable' | translate }}

} @@ -45,6 +45,6 @@
} @else if (showEmptyState()) {
-

No filters available

+

{{ 'collections.filters.noFiltersAvailable' | translate }}

} diff --git a/src/app/shared/components/reusable-filter/reusable-filter.component.ts b/src/app/shared/components/reusable-filter/reusable-filter.component.ts index d5cfbd581..d7d561a33 100644 --- a/src/app/shared/components/reusable-filter/reusable-filter.component.ts +++ b/src/app/shared/components/reusable-filter/reusable-filter.component.ts @@ -117,10 +117,6 @@ export class ReusableFilterComponent { return this.FILTER_PLACEHOLDERS[filterKey] || ''; } - isFilterEditable(filterKey: string): boolean { - return this.FILTER_PLACEHOLDERS[filterKey]?.editable ?? true; - } - getFilterDescription(filter: DiscoverableFilter): string | null { return filter.description || null; } 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 index 7006079bf..b4065483f 100644 --- 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 @@ -9,18 +9,20 @@ (ngModelChange)="selectTab($event)" /> - @if (searchCount() > 10000) { -

10 000+ results

- } @else if (searchCount() > 0) { -

{{ searchCount() }} results

- } @else { -

0 results

- } +

+ @if (searchCount() > 10000) { + 10 000+ {{ 'collections.searchResults.results' | translate }} + } @else if (searchCount() > 0) { + {{ searchCount() }} {{ 'collections.searchResults.results' | translate }} + } @else { + 0 {{ 'collections.searchResults.results' | translate }} + } +

@if (isWeb()) { -

Sort by:

+

{{ 'collections.filters.sortBy' | translate }}:

0 results - +
@if (items.length > 0) { @for (item of items; track item.id) { @@ -99,7 +101,7 @@

0 results

@if (first() && prev()) { - + } Date: Tue, 1 Jul 2025 12:38:33 +0300 Subject: [PATCH 05/10] feat(institution): add search page --- .../institutions/institutions.component.scss | 0 .../institutions/institutions.component.ts | 1 - .../institutions-search.component.html | 134 ++++++++++-------- .../institutions-search.component.scss | 3 + .../institutions-search.component.ts | 13 +- .../generic-filter.component.html | 3 +- .../generic-filter.component.ts | 1 - 7 files changed, 95 insertions(+), 60 deletions(-) delete mode 100644 src/app/features/institutions/institutions.component.scss create mode 100644 src/app/features/institutions/pages/institutions-search/institutions-search.component.scss diff --git a/src/app/features/institutions/institutions.component.scss b/src/app/features/institutions/institutions.component.scss deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/app/features/institutions/institutions.component.ts b/src/app/features/institutions/institutions.component.ts index 6d22a4898..d9c0548ef 100644 --- a/src/app/features/institutions/institutions.component.ts +++ b/src/app/features/institutions/institutions.component.ts @@ -44,7 +44,6 @@ import { FetchInstitutions, InstitutionsSelectors } from '@shared/stores'; RouterLink, ], templateUrl: './institutions.component.html', - styleUrl: './institutions.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class InstitutionsComponent { 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 index 98728ede1..590f22311 100644 --- a/src/app/features/institutions/pages/institutions-search/institutions-search.component.html +++ b/src/app/features/institutions/pages/institutions-search/institutions-search.component.html @@ -1,65 +1,87 @@
-
-
- + @if (isInstitutionLoading()) { +
+
+ } @else { +
+
+ -
- - - @for (item of resourceTabOptions; track $index) { - {{ item.label | translate }} - } - - +

{{ 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 000000000..ee7b455d1 --- /dev/null +++ b/src/app/features/institutions/pages/institutions-search/institutions-search.component.scss @@ -0,0 +1,3 @@ +.fit-contain { + object-fit: contain; +} 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 index 3741c3742..e5d49d747 100644 --- a/src/app/features/institutions/pages/institutions-search/institutions-search.component.ts +++ b/src/app/features/institutions/pages/institutions-search/institutions-search.component.ts @@ -3,13 +3,20 @@ 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 { NgOptimizedImage } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core'; import { FormControl, FormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; -import { ReusableFilterComponent, SearchHelpTutorialComponent, SearchInputComponent } from '@shared/components'; +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'; @@ -41,8 +48,12 @@ import { SearchHelpTutorialComponent, SearchInputComponent, TranslatePipe, + NgOptimizedImage, + LoadingSpinnerComponent, + SafeHtmlPipe, ], templateUrl: './institutions-search.component.html', + styleUrl: './institutions-search.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, }) diff --git a/src/app/shared/components/generic-filter/generic-filter.component.html b/src/app/shared/components/generic-filter/generic-filter.component.html index 3a9b61055..960c85792 100644 --- a/src/app/shared/components/generic-filter/generic-filter.component.html +++ b/src/app/shared/components/generic-filter/generic-filter.component.html @@ -1,4 +1,4 @@ -