diff --git a/src/app/features/home/home.component.ts b/src/app/features/home/home.component.ts index 9c5fd350..87080fcd 100644 --- a/src/app/features/home/home.component.ts +++ b/src/app/features/home/home.component.ts @@ -20,7 +20,7 @@ import { AddProjectFormComponent, MyProjectsTableComponent, SubHeaderComponent } import { SortOrder } from '@osf/shared/enums'; import { TableParameters } from '@osf/shared/models'; import { IS_MEDIUM } from '@osf/shared/utils'; -import { GetUserInstitutions } from '@shared/stores'; +import { FetchUserInstitutions } from '@shared/stores'; import { MyProjectsSearchFilters } from '../my-projects/models'; import { ClearMyProjects, GetMyProjects, MyProjectsSelectors } from '../my-projects/store'; @@ -76,7 +76,7 @@ export class HomeComponent implements OnInit { ngOnInit() { this.setupQueryParamsSubscription(); - this.store.dispatch(new GetUserInstitutions()); + this.store.dispatch(new FetchUserInstitutions()); this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => { const userId = params['userId']; diff --git a/src/app/features/institutions/institutions.component.html b/src/app/features/institutions/institutions.component.html index e485aa10..f0befd48 100644 --- a/src/app/features/institutions/institutions.component.html +++ b/src/app/features/institutions/institutions.component.html @@ -7,7 +7,7 @@ /> @if (institutionsLoading()) { -
+
} @else { @@ -16,11 +16,19 @@
@for (institution of institutions(); track $index) { - + } @if (!institutions().length) { diff --git a/src/app/features/institutions/institutions.component.scss b/src/app/features/institutions/institutions.component.scss deleted file mode 100644 index c84ec277..00000000 --- a/src/app/features/institutions/institutions.component.scss +++ /dev/null @@ -1,3 +0,0 @@ -.image { - object-fit: contain; -} diff --git a/src/app/features/institutions/institutions.component.ts b/src/app/features/institutions/institutions.component.ts index 17e1ea67..dba3a099 100644 --- a/src/app/features/institutions/institutions.component.ts +++ b/src/app/features/institutions/institutions.component.ts @@ -19,7 +19,7 @@ import { } from '@angular/core'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { FormControl } from '@angular/forms'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { parseQueryFilterParams } from '@core/helpers'; import { @@ -41,9 +41,9 @@ import { FetchInstitutions, InstitutionsSelectors } from '@shared/stores'; NgOptimizedImage, CustomPaginatorComponent, LoadingSpinnerComponent, + RouterLink, ], templateUrl: './institutions.component.html', - styleUrl: './institutions.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class InstitutionsComponent { diff --git a/src/app/features/institutions/institutions.routes.ts b/src/app/features/institutions/institutions.routes.ts index 4d506a60..a07caff0 100644 --- a/src/app/features/institutions/institutions.routes.ts +++ b/src/app/features/institutions/institutions.routes.ts @@ -1,10 +1,20 @@ +import { provideStates } from '@ngxs/store'; + import { Routes } from '@angular/router'; import { InstitutionsComponent } from '@osf/features/institutions/institutions.component'; +import { InstitutionsSearchState } from '@shared/stores'; + +import { InstitutionsSearchComponent } from './pages'; export const routes: Routes = [ { path: '', component: InstitutionsComponent, }, + { + path: ':institution-id', + component: InstitutionsSearchComponent, + providers: [provideStates([InstitutionsSearchState])], + }, ]; diff --git a/src/app/features/institutions/pages/index.ts b/src/app/features/institutions/pages/index.ts new file mode 100644 index 00000000..f189ec9b --- /dev/null +++ b/src/app/features/institutions/pages/index.ts @@ -0,0 +1 @@ +export { InstitutionsSearchComponent } from './institutions-search/institutions-search.component'; diff --git a/src/app/features/institutions/pages/institutions-search/institutions-search.component.html b/src/app/features/institutions/pages/institutions-search/institutions-search.component.html new file mode 100644 index 00000000..5accea06 --- /dev/null +++ b/src/app/features/institutions/pages/institutions-search/institutions-search.component.html @@ -0,0 +1,98 @@ +
+ @if (isInstitutionLoading()) { +
+ +
+ } @else { +
+
+ + +

{{ institution().name }}

+
+ +

+
+ +
+
+ +
+ +
+ + +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ + +
+
+
+ } +
diff --git a/src/app/features/institutions/pages/institutions-search/institutions-search.component.scss b/src/app/features/institutions/pages/institutions-search/institutions-search.component.scss new file mode 100644 index 00000000..da0c027b --- /dev/null +++ b/src/app/features/institutions/pages/institutions-search/institutions-search.component.scss @@ -0,0 +1,5 @@ +:host { + display: flex; + flex-direction: column; + flex: 1; +} diff --git a/src/app/features/institutions/pages/institutions-search/institutions-search.component.spec.ts b/src/app/features/institutions/pages/institutions-search/institutions-search.component.spec.ts new file mode 100644 index 00000000..c7068641 --- /dev/null +++ b/src/app/features/institutions/pages/institutions-search/institutions-search.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { InstitutionsSearchComponent } from './institutions-search.component'; + +describe('InstitutionsSearchComponent', () => { + let component: InstitutionsSearchComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [InstitutionsSearchComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(InstitutionsSearchComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/institutions/pages/institutions-search/institutions-search.component.ts b/src/app/features/institutions/pages/institutions-search/institutions-search.component.ts new file mode 100644 index 00000000..5fd29abe --- /dev/null +++ b/src/app/features/institutions/pages/institutions-search/institutions-search.component.ts @@ -0,0 +1,328 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { AutoCompleteModule } from 'primeng/autocomplete'; +import { SafeHtmlPipe } from 'primeng/menu'; +import { Tabs, TabsModule } from 'primeng/tabs'; + +import { debounceTime, distinctUntilChanged } from 'rxjs'; + +import { NgOptimizedImage } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, OnInit, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormControl, FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { + LoadingSpinnerComponent, + ReusableFilterComponent, + SearchHelpTutorialComponent, + SearchInputComponent, +} from '@shared/components'; +import { FilterChipsComponent } from '@shared/components/filter-chips/filter-chips.component'; +import { SearchResultsContainerComponent } from '@shared/components/search-results-container/search-results-container.component'; +import { SEARCH_TAB_OPTIONS } from '@shared/constants'; +import { ResourceTab } from '@shared/enums'; +import { DiscoverableFilter } from '@shared/models'; +import { + FetchInstitutionById, + FetchResources, + FetchResourcesByLink, + InstitutionsSearchSelectors, + LoadFilterOptions, + LoadFilterOptionsAndSetValues, + SetFilterValues, + UpdateFilterValue, + UpdateResourceType, + UpdateSortBy, +} from '@shared/stores'; + +@Component({ + selector: 'osf-institutions-search', + imports: [ + ReusableFilterComponent, + SearchResultsContainerComponent, + FilterChipsComponent, + AutoCompleteModule, + FormsModule, + Tabs, + TabsModule, + SearchHelpTutorialComponent, + SearchInputComponent, + TranslatePipe, + NgOptimizedImage, + LoadingSpinnerComponent, + SafeHtmlPipe, + ], + templateUrl: './institutions-search.component.html', + styleUrl: './institutions-search.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class InstitutionsSearchComponent implements OnInit { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + + institution = select(InstitutionsSearchSelectors.getInstitution); + isInstitutionLoading = select(InstitutionsSearchSelectors.getInstitutionLoading); + resources = select(InstitutionsSearchSelectors.getResources); + isResourcesLoading = select(InstitutionsSearchSelectors.getResourcesLoading); + resourcesCount = select(InstitutionsSearchSelectors.getResourcesCount); + filters = select(InstitutionsSearchSelectors.getFilters); + selectedValues = select(InstitutionsSearchSelectors.getFilterValues); + selectedSort = select(InstitutionsSearchSelectors.getSortBy); + first = select(InstitutionsSearchSelectors.getFirst); + next = select(InstitutionsSearchSelectors.getNext); + previous = select(InstitutionsSearchSelectors.getPrevious); + + private readonly actions = createDispatchMap({ + fetchInstitution: FetchInstitutionById, + updateResourceType: UpdateResourceType, + updateSortBy: UpdateSortBy, + loadFilterOptions: LoadFilterOptions, + loadFilterOptionsAndSetValues: LoadFilterOptionsAndSetValues, + setFilterValues: SetFilterValues, + updateFilterValue: UpdateFilterValue, + fetchResourcesByLink: FetchResourcesByLink, + fetchResources: FetchResources, + }); + protected readonly resourceTabOptions = SEARCH_TAB_OPTIONS; + + private readonly tabUrlMap = new Map( + SEARCH_TAB_OPTIONS.map((option) => [option.value, option.label.split('.').pop()?.toLowerCase() || 'all']) + ); + + private readonly urlTabMap = new Map( + SEARCH_TAB_OPTIONS.map((option) => [option.label.split('.').pop()?.toLowerCase() || 'all', option.value]) + ); + + protected searchControl = new FormControl(''); + protected selectedTab: ResourceTab = ResourceTab.All; + protected currentStep = signal(0); + protected isFiltersOpen = signal(true); + protected isSortingOpen = signal(false); + + readonly resourceTab = ResourceTab; + readonly resourceType = select(InstitutionsSearchSelectors.getResourceType); + + readonly filterLabels = computed(() => { + const filtersData = this.filters(); + const labels: Record = {}; + filtersData.forEach((filter) => { + if (filter.key && filter.label) { + labels[filter.key] = filter.label; + } + }); + return labels; + }); + + readonly filterOptions = computed(() => { + const filtersData = this.filters(); + const options: Record = {}; + filtersData.forEach((filter) => { + if (filter.key && filter.options) { + options[filter.key] = filter.options.map((opt) => ({ + id: String(opt.value || ''), + value: String(opt.value || ''), + label: opt.label, + })); + } + }); + return options; + }); + + ngOnInit(): void { + this.restoreFiltersFromUrl(); + this.restoreTabFromUrl(); + this.restoreSearchFromUrl(); + this.handleSearch(); + + const institutionId = this.route.snapshot.params['institution-id']; + if (institutionId) { + this.actions.fetchInstitution(institutionId); + } + } + + onLoadFilterOptions(event: { filterType: string; filter: DiscoverableFilter }): void { + this.actions.loadFilterOptions(event.filterType); + } + + onFilterChanged(event: { filterType: string; value: string | null }): void { + this.actions.updateFilterValue(event.filterType, event.value); + + const currentFilters = this.selectedValues(); + const updatedFilters = { + ...currentFilters, + [event.filterType]: event.value, + }; + + Object.keys(updatedFilters).forEach((key) => { + if (!updatedFilters[key]) { + delete updatedFilters[key]; + } + }); + + this.updateUrlWithFilters(updatedFilters); + } + + showTutorial() { + this.currentStep.set(1); + } + + onTabChange(index: ResourceTab): void { + this.selectedTab = index; + this.actions.updateResourceType(index); + this.updateUrlWithTab(index); + this.actions.fetchResources(); + } + + onSortChanged(sort: string): void { + this.actions.updateSortBy(sort); + this.actions.fetchResources(); + } + + onPageChanged(link: string): void { + this.actions.fetchResourcesByLink(link); + } + + onFiltersToggled(): void { + this.isFiltersOpen.update((open) => !open); + this.isSortingOpen.set(false); + } + + onSortingToggled(): void { + this.isSortingOpen.update((open) => !open); + this.isFiltersOpen.set(false); + } + + onFilterChipRemoved(filterKey: string): void { + this.actions.updateFilterValue(filterKey, null); + + const currentFilters = this.selectedValues(); + const updatedFilters = { ...currentFilters }; + delete updatedFilters[filterKey]; + this.updateUrlWithFilters(updatedFilters); + + this.actions.fetchResources(); + } + + onAllFiltersCleared(): void { + this.actions.setFilterValues({}); + + this.searchControl.setValue('', { emitEvent: false }); + this.actions.updateFilterValue('search', ''); + + const queryParams: Record = { ...this.route.snapshot.queryParams }; + + Object.keys(queryParams).forEach((key) => { + if (key.startsWith('filter_')) { + delete queryParams[key]; + } + }); + + delete queryParams['search']; + + this.router.navigate([], { + relativeTo: this.route, + queryParams, + queryParamsHandling: 'replace', + replaceUrl: true, + }); + } + + private restoreFiltersFromUrl(): void { + const queryParams = this.route.snapshot.queryParams; + const filterValues: Record = {}; + + Object.keys(queryParams).forEach((key) => { + if (key.startsWith('filter_')) { + const filterKey = key.replace('filter_', ''); + const filterValue = queryParams[key]; + if (filterValue) { + filterValues[filterKey] = filterValue; + } + } + }); + + if (Object.keys(filterValues).length > 0) { + this.actions.loadFilterOptionsAndSetValues(filterValues); + } + } + + private updateUrlWithFilters(filterValues: Record): void { + const queryParams: Record = { ...this.route.snapshot.queryParams }; + + Object.keys(queryParams).forEach((key) => { + if (key.startsWith('filter_')) { + delete queryParams[key]; + } + }); + + Object.entries(filterValues).forEach(([key, value]) => { + if (value && value.trim() !== '') { + queryParams[`filter_${key}`] = value; + } + }); + + this.router.navigate([], { + relativeTo: this.route, + queryParams, + queryParamsHandling: 'replace', + replaceUrl: true, + }); + } + + private updateUrlWithTab(tab: ResourceTab): void { + const queryParams: Record = { ...this.route.snapshot.queryParams }; + + if (tab !== ResourceTab.All) { + queryParams['tab'] = this.tabUrlMap.get(tab) || 'all'; + } else { + delete queryParams['tab']; + } + + this.router.navigate([], { + relativeTo: this.route, + queryParams, + queryParamsHandling: 'replace', + replaceUrl: true, + }); + } + + private restoreTabFromUrl(): void { + const queryParams = this.route.snapshot.queryParams; + const tabString = queryParams['tab']; + if (tabString) { + const tab = this.urlTabMap.get(tabString); + if (tab !== undefined) { + this.selectedTab = tab; + this.actions.updateResourceType(tab); + } + } + } + + private restoreSearchFromUrl(): void { + const queryParams = this.route.snapshot.queryParams; + const searchTerm = queryParams['search']; + if (searchTerm) { + this.searchControl.setValue(searchTerm, { emitEvent: false }); + this.actions.updateFilterValue('search', searchTerm); + } + } + + private handleSearch(): void { + this.searchControl.valueChanges + .pipe(debounceTime(1000), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (newValue) => { + this.actions.updateFilterValue('search', newValue); + this.router.navigate([], { + relativeTo: this.route, + queryParams: { search: newValue }, + queryParamsHandling: 'merge', + }); + }, + }); + } +} diff --git a/src/app/features/my-projects/my-projects.component.ts b/src/app/features/my-projects/my-projects.component.ts index 19fcb632..35e71d56 100644 --- a/src/app/features/my-projects/my-projects.component.ts +++ b/src/app/features/my-projects/my-projects.component.ts @@ -31,7 +31,7 @@ import { SortOrder } from '@osf/shared/enums'; import { QueryParams, TableParameters, TabOption } from '@osf/shared/models'; import { IS_MEDIUM, IS_WEB, IS_XSMALL } from '@osf/shared/utils'; -import { GetUserInstitutions } from '../../shared/stores/institutions'; +import { FetchUserInstitutions } from '../../shared/stores/institutions'; import { CollectionsSelectors, GetBookmarksCollectionId } from '../collections/store'; import { MyProjectsItem, MyProjectsSearchFilters } from './models'; @@ -128,7 +128,7 @@ export class MyProjectsComponent implements OnInit { } ngOnInit(): void { - this.#store.dispatch(new GetUserInstitutions()); + this.#store.dispatch(new FetchUserInstitutions()); this.#store.dispatch(new GetBookmarksCollectionId()); } diff --git a/src/app/features/search/mappers/search.mapper.ts b/src/app/features/search/mappers/search.mapper.ts index f177c368..5d365a1e 100644 --- a/src/app/features/search/mappers/search.mapper.ts +++ b/src/app/features/search/mappers/search.mapper.ts @@ -39,5 +39,5 @@ export function MapResources(rawItem: ResourceItem): Resource { hasMaterialsResource: !!rawItem?.hasMaterialsResource, hasPapersResource: !!rawItem?.hasPapersResource, hasSupplementalResource: !!rawItem?.hasSupplementalResource, - }; + } as Resource; } diff --git a/src/app/features/search/models/raw-models/index-card-search.model.ts b/src/app/features/search/models/raw-models/index-card-search.model.ts index fc37d736..521332d1 100644 --- a/src/app/features/search/models/raw-models/index-card-search.model.ts +++ b/src/app/features/search/models/raw-models/index-card-search.model.ts @@ -1,10 +1,14 @@ import { ApiData, JsonApiResponse } from '@osf/core/models'; +import { AppliedFilter, RelatedPropertyPathAttributes } from '@shared/mappers'; import { ResourceItem } from './resource-response.model'; export type IndexCardSearch = JsonApiResponse< { - attributes: { totalResultCount: number }; + attributes: { + totalResultCount: number; + cardSearchFilter?: AppliedFilter[]; + }; relationships: { searchResultPage: { links: { @@ -21,5 +25,8 @@ export type IndexCardSearch = JsonApiResponse< }; }; }, - ApiData<{ resourceMetadata: ResourceItem }, null, null, null>[] + ( + | ApiData<{ resourceMetadata: ResourceItem }, null, null, null> + | ApiData + )[] >; diff --git a/src/app/features/search/models/resources-data.model.ts b/src/app/features/search/models/resources-data.model.ts index 3b708bc3..c9157d4b 100644 --- a/src/app/features/search/models/resources-data.model.ts +++ b/src/app/features/search/models/resources-data.model.ts @@ -1,7 +1,8 @@ -import { Resource } from '@osf/shared/models'; +import { DiscoverableFilter, Resource } from '@osf/shared/models'; export interface ResourcesData { resources: Resource[]; + filters: DiscoverableFilter[]; count: number; first: string; next: string; diff --git a/src/app/shared/components/filter-chips/filter-chips.component.html b/src/app/shared/components/filter-chips/filter-chips.component.html new file mode 100644 index 00000000..5533f3df --- /dev/null +++ b/src/app/shared/components/filter-chips/filter-chips.component.html @@ -0,0 +1,12 @@ +@if (chips().length > 0) { +
+ @for (chip of chips(); track chip.key + chip.value) { + + } +
+} diff --git a/src/app/shared/components/filter-chips/filter-chips.component.spec.ts b/src/app/shared/components/filter-chips/filter-chips.component.spec.ts new file mode 100644 index 00000000..6869018f --- /dev/null +++ b/src/app/shared/components/filter-chips/filter-chips.component.spec.ts @@ -0,0 +1,243 @@ +import { ComponentRef } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { FilterChipsComponent } from './filter-chips.component'; + +describe('FilterChipsComponent', () => { + let component: FilterChipsComponent; + let fixture: ComponentFixture; + let componentRef: ComponentRef; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [FilterChipsComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(FilterChipsComponent); + component = fixture.componentInstance; + componentRef = fixture.componentRef; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('Component Initialization', () => { + it('should have default input values', () => { + expect(component.selectedValues()).toEqual({}); + expect(component.filterLabels()).toEqual({}); + expect(component.filterOptions()).toEqual({}); + }); + + it('should not display anything when no chips are present', () => { + fixture.detectChanges(); + const chipContainer = fixture.debugElement.query(By.css('.flex.flex-wrap')); + expect(chipContainer).toBeFalsy(); + }); + }); + + describe('Chips Display', () => { + beforeEach(() => { + // Set up test data + componentRef.setInput('selectedValues', { + subject: 'psychology', + resourceType: 'project', + }); + componentRef.setInput('filterLabels', { + subject: 'Subject', + resourceType: 'Resource Type', + }); + componentRef.setInput('filterOptions', { + subject: [{ id: 'psychology', value: 'psychology', label: 'Psychology' }], + resourceType: [{ id: 'project', value: 'project', label: 'Project' }], + }); + fixture.detectChanges(); + }); + + it('should display chips for selected values', () => { + const chips = fixture.debugElement.queryAll(By.css('.filter-chip')); + expect(chips.length).toBe(2); + }); + + it('should display correct chip labels and values', () => { + const chips = fixture.debugElement.queryAll(By.css('.chip-label')); + const chipTexts = chips.map((chip) => chip.nativeElement.textContent.trim()); + + expect(chipTexts).toContain('Subject: Psychology'); + expect(chipTexts).toContain('Resource Type: Project'); + }); + + it('should display remove button for each chip', () => { + const removeButtons = fixture.debugElement.queryAll(By.css('.chip-remove')); + expect(removeButtons.length).toBe(2); + }); + + it('should display clear all button when multiple chips are present', () => { + const clearAllButton = fixture.debugElement.query(By.css('.clear-all-btn')); + expect(clearAllButton).toBeTruthy(); + expect(clearAllButton.nativeElement.textContent.trim()).toBe('Clear all'); + }); + + it('should have proper aria-label for remove buttons', () => { + const removeButtons = fixture.debugElement.queryAll(By.css('.chip-remove')); + const ariaLabels = removeButtons.map((btn) => btn.nativeElement.getAttribute('aria-label')); + + expect(ariaLabels).toContain('Remove Subject filter'); + expect(ariaLabels).toContain('Remove Resource Type filter'); + }); + }); + + describe('Single Chip Behavior', () => { + beforeEach(() => { + componentRef.setInput('selectedValues', { + subject: 'psychology', + }); + componentRef.setInput('filterLabels', { + subject: 'Subject', + }); + componentRef.setInput('filterOptions', { + subject: [{ id: 'psychology', value: 'psychology', label: 'Psychology' }], + }); + fixture.detectChanges(); + }); + + it('should not display clear all button for single chip', () => { + const clearAllButton = fixture.debugElement.query(By.css('.clear-all-btn')); + expect(clearAllButton).toBeFalsy(); + }); + + it('should still display remove button for single chip', () => { + const removeButtons = fixture.debugElement.queryAll(By.css('.chip-remove')); + expect(removeButtons.length).toBe(1); + }); + }); + + describe('Event Emissions', () => { + beforeEach(() => { + componentRef.setInput('selectedValues', { + subject: 'psychology', + resourceType: 'project', + }); + componentRef.setInput('filterLabels', { + subject: 'Subject', + resourceType: 'Resource Type', + }); + fixture.detectChanges(); + }); + + it('should emit filterRemoved when remove button is clicked', () => { + spyOn(component.filterRemoved, 'emit'); + + const removeButtons = fixture.debugElement.queryAll(By.css('.chip-remove')); + removeButtons[0].nativeElement.click(); + + expect(component.filterRemoved.emit).toHaveBeenCalledWith('subject'); + }); + + it('should emit allFiltersCleared when clear all button is clicked', () => { + spyOn(component.allFiltersCleared, 'emit'); + + const clearAllButton = fixture.debugElement.query(By.css('.clear-all-btn')); + clearAllButton.nativeElement.click(); + + expect(component.allFiltersCleared.emit).toHaveBeenCalled(); + }); + }); + + describe('Chips Computed Property', () => { + it('should filter out null and empty values', () => { + componentRef.setInput('selectedValues', { + subject: 'psychology', + resourceType: null, + creator: '', + funder: 'nsf', + }); + componentRef.setInput('filterLabels', { + subject: 'Subject', + funder: 'Funder', + }); + fixture.detectChanges(); + + const chips = component.chips(); + expect(chips.length).toBe(2); + expect(chips.map((c) => c.key)).toEqual(['subject', 'funder']); + }); + + it('should use raw value when no option label is found', () => { + componentRef.setInput('selectedValues', { + subject: 'unknown-subject', + }); + componentRef.setInput('filterLabels', { + subject: 'Subject', + }); + componentRef.setInput('filterOptions', { + subject: [{ id: 'psychology', value: 'psychology', label: 'Psychology' }], + }); + fixture.detectChanges(); + + const chips = component.chips(); + expect(chips[0].displayValue).toBe('unknown-subject'); + }); + + it('should use filter key as label when no label is provided', () => { + componentRef.setInput('selectedValues', { + subject: 'psychology', + }); + componentRef.setInput('filterLabels', {}); + fixture.detectChanges(); + + const chips = component.chips(); + expect(chips[0].label).toBe('subject'); + }); + }); + + describe('Component Methods', () => { + it('should call filterRemoved.emit with correct parameter in removeFilter', () => { + spyOn(component.filterRemoved, 'emit'); + + component.removeFilter('testKey'); + + expect(component.filterRemoved.emit).toHaveBeenCalledWith('testKey'); + }); + + it('should call allFiltersCleared.emit in clearAllFilters', () => { + spyOn(component.allFiltersCleared, 'emit'); + + component.clearAllFilters(); + + expect(component.allFiltersCleared.emit).toHaveBeenCalled(); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty filter options gracefully', () => { + componentRef.setInput('selectedValues', { + subject: 'psychology', + }); + componentRef.setInput('filterLabels', { + subject: 'Subject', + }); + componentRef.setInput('filterOptions', {}); + fixture.detectChanges(); + + const chips = component.chips(); + expect(chips[0].displayValue).toBe('psychology'); + }); + + it('should handle undefined filter options gracefully', () => { + componentRef.setInput('selectedValues', { + subject: 'psychology', + }); + componentRef.setInput('filterLabels', { + subject: 'Subject', + }); + // filterOptions not set (undefined) + fixture.detectChanges(); + + expect(() => fixture.detectChanges()).not.toThrow(); + const chips = component.chips(); + expect(chips[0].displayValue).toBe('psychology'); + }); + }); +}); diff --git a/src/app/shared/components/filter-chips/filter-chips.component.ts b/src/app/shared/components/filter-chips/filter-chips.component.ts new file mode 100644 index 00000000..8cd87f2e --- /dev/null +++ b/src/app/shared/components/filter-chips/filter-chips.component.ts @@ -0,0 +1,49 @@ +import { Chip } from 'primeng/chip'; + +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; + +@Component({ + selector: 'osf-filter-chips', + imports: [CommonModule, Chip], + templateUrl: './filter-chips.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FilterChipsComponent { + selectedValues = input>({}); + filterLabels = input>({}); + filterOptions = input>({}); + + filterRemoved = output(); + allFiltersCleared = output(); + + readonly chips = computed(() => { + const values = this.selectedValues(); + const labels = this.filterLabels(); + const options = this.filterOptions(); + + return Object.entries(values) + .filter(([key, value]) => value !== null && value !== '') + .map(([key, value]) => { + const filterLabel = labels[key] || key; + const filterOptionsList = options[key] || []; + const option = filterOptionsList.find((opt) => opt.value === value || opt.id === value); + const displayValue = option?.label || value || ''; + + return { + key, + value: value!, + label: filterLabel, + displayValue, + }; + }); + }); + + removeFilter(filterKey: string): void { + this.filterRemoved.emit(filterKey); + } + + clearAllFilters(): void { + this.allFiltersCleared.emit(); + } +} diff --git a/src/app/shared/components/generic-filter/generic-filter.component.html b/src/app/shared/components/generic-filter/generic-filter.component.html new file mode 100644 index 00000000..960c8579 --- /dev/null +++ b/src/app/shared/components/generic-filter/generic-filter.component.html @@ -0,0 +1,22 @@ + diff --git a/src/app/shared/components/generic-filter/generic-filter.component.spec.ts b/src/app/shared/components/generic-filter/generic-filter.component.spec.ts new file mode 100644 index 00000000..14c1deed --- /dev/null +++ b/src/app/shared/components/generic-filter/generic-filter.component.spec.ts @@ -0,0 +1,394 @@ +import { Select, SelectChangeEvent } from 'primeng/select'; + +import { ComponentRef } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { By } from '@angular/platform-browser'; + +import { LoadingSpinnerComponent } from '@shared/components'; +import { SelectOption } from '@shared/models'; + +import { GenericFilterComponent } from './generic-filter.component'; + +describe('GenericFilterComponent', () => { + let component: GenericFilterComponent; + let fixture: ComponentFixture; + let componentRef: ComponentRef; + + const mockOptions: SelectOption[] = [ + { label: 'Option 1', value: 'value1' }, + { label: 'Option 2', value: 'value2' }, + { label: 'Option 3', value: 'value3' }, + ]; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [GenericFilterComponent, FormsModule, Select, LoadingSpinnerComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(GenericFilterComponent); + component = fixture.componentInstance; + componentRef = fixture.componentRef; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('Input Properties', () => { + it('should initialize with default values', () => { + expect(component.options()).toEqual([]); + expect(component.isLoading()).toBe(false); + expect(component.selectedValue()).toBeNull(); + expect(component.placeholder()).toBe(''); + expect(component.editable()).toBe(false); + expect(component.filterType()).toBe(''); + }); + + it('should accept options input', () => { + componentRef.setInput('options', mockOptions); + fixture.detectChanges(); + + expect(component.options()).toEqual(mockOptions); + }); + + it('should accept isLoading input', () => { + componentRef.setInput('isLoading', true); + fixture.detectChanges(); + + expect(component.isLoading()).toBe(true); + }); + + it('should accept selectedValue input', () => { + componentRef.setInput('selectedValue', 'value1'); + fixture.detectChanges(); + + expect(component.selectedValue()).toBe('value1'); + }); + + it('should accept placeholder input', () => { + componentRef.setInput('placeholder', 'Select an option'); + fixture.detectChanges(); + + expect(component.placeholder()).toBe('Select an option'); + }); + + it('should accept editable input', () => { + componentRef.setInput('editable', true); + fixture.detectChanges(); + + expect(component.editable()).toBe(true); + }); + + it('should accept filterType input', () => { + componentRef.setInput('filterType', 'subject'); + fixture.detectChanges(); + + expect(component.filterType()).toBe('subject'); + }); + }); + + describe('Computed Properties', () => { + it('should return empty array when no options provided', () => { + expect(component.filterOptions()).toEqual([]); + }); + + it('should filter out options without labels', () => { + const optionsWithEmpty: SelectOption[] = [ + { label: 'Valid Option', value: 'valid' }, + { label: '', value: 'empty' }, + { label: 'Another Valid', value: 'valid2' }, + ]; + + componentRef.setInput('options', optionsWithEmpty); + fixture.detectChanges(); + + const filteredOptions = component.filterOptions(); + expect(filteredOptions).toHaveLength(2); + expect(filteredOptions[0].label).toBe('Valid Option'); + expect(filteredOptions[1].label).toBe('Another Valid'); + }); + + it('should map options correctly', () => { + componentRef.setInput('options', mockOptions); + fixture.detectChanges(); + + const filteredOptions = component.filterOptions(); + expect(filteredOptions).toHaveLength(3); + expect(filteredOptions[0]).toEqual({ label: 'Option 1', value: 'value1' }); + expect(filteredOptions[1]).toEqual({ label: 'Option 2', value: 'value2' }); + }); + }); + + describe('Current Selected Option Signal', () => { + beforeEach(() => { + componentRef.setInput('options', mockOptions); + fixture.detectChanges(); + }); + + it('should set currentSelectedOption to null when no value selected', () => { + componentRef.setInput('selectedValue', null); + fixture.detectChanges(); + + expect(component.currentSelectedOption()).toBeNull(); + }); + + it('should set currentSelectedOption when selectedValue matches an option', () => { + componentRef.setInput('selectedValue', 'value2'); + fixture.detectChanges(); + + const currentOption = component.currentSelectedOption(); + expect(currentOption).toEqual({ label: 'Option 2', value: 'value2' }); + }); + + it('should set currentSelectedOption to null when selectedValue does not match any option', () => { + componentRef.setInput('selectedValue', 'nonexistent'); + fixture.detectChanges(); + + expect(component.currentSelectedOption()).toBeNull(); + }); + + it('should update currentSelectedOption when selectedValue changes', () => { + componentRef.setInput('selectedValue', 'value1'); + fixture.detectChanges(); + + expect(component.currentSelectedOption()).toEqual({ label: 'Option 1', value: 'value1' }); + + componentRef.setInput('selectedValue', 'value3'); + fixture.detectChanges(); + + expect(component.currentSelectedOption()).toEqual({ label: 'Option 3', value: 'value3' }); + }); + }); + + describe('Template Rendering', () => { + it('should show loading spinner when isLoading is true', () => { + componentRef.setInput('isLoading', true); + fixture.detectChanges(); + + const loadingSpinner = fixture.debugElement.query(By.directive(LoadingSpinnerComponent)); + const selectElement = fixture.debugElement.query(By.directive(Select)); + + expect(loadingSpinner).toBeTruthy(); + expect(selectElement).toBeFalsy(); + }); + + it('should show select component when isLoading is false', () => { + componentRef.setInput('isLoading', false); + fixture.detectChanges(); + + const loadingSpinner = fixture.debugElement.query(By.directive(LoadingSpinnerComponent)); + const selectElement = fixture.debugElement.query(By.directive(Select)); + + expect(loadingSpinner).toBeFalsy(); + expect(selectElement).toBeTruthy(); + }); + + it('should pass correct properties to p-select', () => { + componentRef.setInput('options', mockOptions); + componentRef.setInput('selectedValue', 'value1'); + componentRef.setInput('placeholder', 'Choose option'); + componentRef.setInput('editable', true); + componentRef.setInput('filterType', 'subject'); + fixture.detectChanges(); + + const selectElement = fixture.debugElement.query(By.directive(Select)); + const selectComponent = selectElement.componentInstance; + + expect(selectComponent.id).toBe('subject'); + expect(selectComponent.options).toEqual(component.filterOptions()); + expect(selectComponent.optionLabel).toBe('label'); + expect(selectComponent.optionValue).toBe('value'); + expect(selectComponent.ngModel).toBe('value1'); + expect(selectComponent.editable).toBe(true); + expect(selectComponent.styleClass).toBe('w-full'); + expect(selectComponent.appendTo).toBe('body'); + expect(selectComponent.filter).toBe(true); + expect(selectComponent.showClear).toBe(true); + }); + + it('should show selected option label as placeholder when option is selected', () => { + componentRef.setInput('options', mockOptions); + componentRef.setInput('selectedValue', 'value2'); + componentRef.setInput('placeholder', 'Default placeholder'); + fixture.detectChanges(); + + const selectElement = fixture.debugElement.query(By.directive(Select)); + const selectComponent = selectElement.componentInstance; + + expect(selectComponent.placeholder).toBe('Option 2'); + }); + + it('should show default placeholder when no option is selected', () => { + componentRef.setInput('options', mockOptions); + componentRef.setInput('selectedValue', null); + componentRef.setInput('placeholder', 'Default placeholder'); + fixture.detectChanges(); + + const selectElement = fixture.debugElement.query(By.directive(Select)); + const selectComponent = selectElement.componentInstance; + + expect(selectComponent.placeholder).toBe('Default placeholder'); + }); + + it('should not show clear button when no value is selected', () => { + componentRef.setInput('selectedValue', null); + fixture.detectChanges(); + + const selectElement = fixture.debugElement.query(By.directive(Select)); + const selectComponent = selectElement.componentInstance; + + expect(selectComponent.showClear).toBe(false); + }); + }); + + describe('Event Handling', () => { + beforeEach(() => { + componentRef.setInput('options', mockOptions); + fixture.detectChanges(); + }); + + it('should emit valueChanged when onValueChange is called with a value', () => { + spyOn(component.valueChanged, 'emit'); + + const mockEvent: SelectChangeEvent = { + originalEvent: new Event('change'), + value: 'value2', + }; + + component.onValueChange(mockEvent); + + expect(component.valueChanged.emit).toHaveBeenCalledWith('value2'); + }); + + it('should emit null when onValueChange is called with null value', () => { + spyOn(component.valueChanged, 'emit'); + + const mockEvent: SelectChangeEvent = { + originalEvent: new Event('change'), + value: null, + }; + + component.onValueChange(mockEvent); + + expect(component.valueChanged.emit).toHaveBeenCalledWith(null); + }); + + it('should update currentSelectedOption when onValueChange is called', () => { + const mockEvent: SelectChangeEvent = { + originalEvent: new Event('change'), + value: 'value3', + }; + + component.onValueChange(mockEvent); + + expect(component.currentSelectedOption()).toEqual({ label: 'Option 3', value: 'value3' }); + }); + + it('should set currentSelectedOption to null when clearing selection', () => { + // First select an option + componentRef.setInput('selectedValue', 'value1'); + fixture.detectChanges(); + + expect(component.currentSelectedOption()).toEqual({ label: 'Option 1', value: 'value1' }); + + // Then clear it + const mockEvent: SelectChangeEvent = { + originalEvent: new Event('change'), + value: null, + }; + + component.onValueChange(mockEvent); + + expect(component.currentSelectedOption()).toBeNull(); + }); + + it('should trigger onChange event in template', () => { + spyOn(component, 'onValueChange'); + + const selectElement = fixture.debugElement.query(By.directive(Select)); + const selectComponent = selectElement.componentInstance; + + const mockEvent: SelectChangeEvent = { + originalEvent: new Event('change'), + value: 'value1', + }; + + selectComponent.onChange.emit(mockEvent); + + expect(component.onValueChange).toHaveBeenCalledWith(mockEvent); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty options array', () => { + componentRef.setInput('options', []); + fixture.detectChanges(); + + expect(component.filterOptions()).toEqual([]); + expect(component.currentSelectedOption()).toBeNull(); + }); + + it('should handle options with null or undefined labels', () => { + const problematicOptions = [ + { label: 'Valid', value: 'valid' }, + { label: null, value: 'null-label' }, + { label: undefined, value: 'undefined-label' }, + ]; + + componentRef.setInput('options', problematicOptions); + fixture.detectChanges(); + + const filteredOptions = component.filterOptions(); + expect(filteredOptions).toHaveLength(1); + expect(filteredOptions[0].label).toBe('Valid'); + }); + + it('should handle selectedValue that becomes invalid when options change', () => { + componentRef.setInput('options', mockOptions); + componentRef.setInput('selectedValue', 'value2'); + fixture.detectChanges(); + + expect(component.currentSelectedOption()).toEqual({ label: 'Option 2', value: 'value2' }); + + // Change options to not include the selected value + const newOptions: SelectOption[] = [{ label: 'New Option', value: 'new-value' }]; + componentRef.setInput('options', newOptions); + fixture.detectChanges(); + + expect(component.currentSelectedOption()).toBeNull(); + }); + }); + + describe('Accessibility', () => { + it('should set proper id attribute for the select element', () => { + componentRef.setInput('filterType', 'subject-filter'); + fixture.detectChanges(); + + const selectElement = fixture.debugElement.query(By.directive(Select)); + const selectComponent = selectElement.componentInstance; + + expect(selectComponent.id).toBe('subject-filter'); + }); + + it('should enable filter when editable is true', () => { + componentRef.setInput('editable', true); + fixture.detectChanges(); + + const selectElement = fixture.debugElement.query(By.directive(Select)); + const selectComponent = selectElement.componentInstance; + + expect(selectComponent.filter).toBe(true); + }); + + it('should disable filter when editable is false', () => { + componentRef.setInput('editable', false); + fixture.detectChanges(); + + const selectElement = fixture.debugElement.query(By.directive(Select)); + const selectComponent = selectElement.componentInstance; + + expect(selectComponent.filter).toBe(false); + }); + }); +}); diff --git a/src/app/shared/components/generic-filter/generic-filter.component.ts b/src/app/shared/components/generic-filter/generic-filter.component.ts new file mode 100644 index 00000000..bea8cbac --- /dev/null +++ b/src/app/shared/components/generic-filter/generic-filter.component.ts @@ -0,0 +1,60 @@ +import { Select, SelectChangeEvent } from 'primeng/select'; + +import { ChangeDetectionStrategy, Component, computed, effect, input, output, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +import { LoadingSpinnerComponent } from '@shared/components'; +import { SelectOption } from '@shared/models'; + +@Component({ + selector: 'osf-generic-filter', + imports: [Select, FormsModule, LoadingSpinnerComponent], + templateUrl: './generic-filter.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class GenericFilterComponent { + options = input([]); + isLoading = input(false); + selectedValue = input(null); + placeholder = input(''); + filterType = input(''); + + valueChanged = output(); + + currentSelectedOption = signal(null); + + filterOptions = computed(() => { + const parentOptions = this.options(); + if (parentOptions.length > 0) { + return parentOptions + .filter((option) => option?.label) + .map((option) => ({ + label: option.label || '', + value: option.value || '', + })); + } + return []; + }); + + constructor() { + effect(() => { + const selectedValue = this.selectedValue(); + const options = this.filterOptions(); + + if (!selectedValue) { + this.currentSelectedOption.set(null); + } else { + const option = options.find((opt) => opt.value === selectedValue); + this.currentSelectedOption.set(option || null); + } + }); + } + + onValueChange(event: SelectChangeEvent): void { + const options = this.filterOptions(); + const selectedOption = event.value ? options.find((opt) => opt.value === event.value) : null; + this.currentSelectedOption.set(selectedOption || null); + + this.valueChanged.emit(event.value || null); + } +} diff --git a/src/app/shared/components/index.ts b/src/app/shared/components/index.ts index 0ef79a98..316973be 100644 --- a/src/app/shared/components/index.ts +++ b/src/app/shared/components/index.ts @@ -8,8 +8,10 @@ export { EmploymentHistoryComponent } from './employment-history/employment-hist export { EmploymentHistoryDialogComponent } from './employment-history-dialog/employment-history-dialog.component'; export { FileMenuComponent } from './file-menu/file-menu.component'; export { FilesTreeComponent } from './files-tree/files-tree.component'; +export { FilterChipsComponent } from './filter-chips/filter-chips.component'; export { FormSelectComponent } from './form-select/form-select.component'; export { FullScreenLoaderComponent } from './full-screen-loader/full-screen-loader.component'; +export { GenericFilterComponent } from './generic-filter/generic-filter.component'; export { IconComponent } from './icon/icon.component'; export { InfoIconComponent } from './info-icon/info-icon.component'; export { LineChartComponent } from './line-chart/line-chart.component'; @@ -19,8 +21,10 @@ export { MyProjectsTableComponent } from './my-projects-table/my-projects-table. export { PasswordInputHintComponent } from './password-input-hint/password-input-hint.component'; export { PieChartComponent } from './pie-chart/pie-chart.component'; export { ResourceCardComponent } from './resource-card/resource-card.component'; +export { ReusableFilterComponent } from './reusable-filter/reusable-filter.component'; export { SearchHelpTutorialComponent } from './search-help-tutorial/search-help-tutorial.component'; export { SearchInputComponent } from './search-input/search-input.component'; +export { SearchResultsContainerComponent } from './search-results-container/search-results-container.component'; export { SelectComponent } from './select/select.component'; export { StepperComponent } from './stepper/stepper.component'; export { SubHeaderComponent } from './sub-header/sub-header.component'; diff --git a/src/app/shared/components/reusable-filter/reusable-filter.component.html b/src/app/shared/components/reusable-filter/reusable-filter.component.html new file mode 100644 index 00000000..98cc026f --- /dev/null +++ b/src/app/shared/components/reusable-filter/reusable-filter.component.html @@ -0,0 +1,50 @@ +@if (isLoading()) { +
+ +
+} @else if (hasVisibleFilters()) { +
+ + @for (filter of visibleFilters(); track filter.key) { + + {{ getFilterLabel(filter) }} + + @if (getFilterDescription(filter)) { +

{{ getFilterDescription(filter) }}

+ } + + @if (getFilterHelpLink(filter) && getFilterHelpLinkText(filter)) { +

+ + {{ getFilterHelpLinkText(filter) }} + +

+ } + + @if (hasFilterContent(filter)) { + + } @else { +

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

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

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

+
+} diff --git a/src/app/shared/components/reusable-filter/reusable-filter.component.spec.ts b/src/app/shared/components/reusable-filter/reusable-filter.component.spec.ts new file mode 100644 index 00000000..1347e7bb --- /dev/null +++ b/src/app/shared/components/reusable-filter/reusable-filter.component.spec.ts @@ -0,0 +1,469 @@ +import { ComponentRef } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { FILTER_PLACEHOLDERS } from '@shared/constants/filter-placeholders'; +import { DiscoverableFilter } from '@shared/models'; + +import { ReusableFilterComponent } from './reusable-filter.component'; + +describe('ReusableFilterComponent', () => { + let component: ReusableFilterComponent; + let fixture: ComponentFixture; + let componentRef: ComponentRef; + + const mockFilters: DiscoverableFilter[] = [ + { + key: 'subject', + label: 'Subject', + type: 'select', + operator: 'eq', + description: 'Filter by subject area', + helpLink: 'https://help.example.com/subjects', + helpLinkText: 'Learn about subjects', + resultCount: 150, + hasOptions: true, + options: [ + { label: 'Psychology', value: 'psychology' }, + { label: 'Biology', value: 'biology' }, + ], + }, + { + key: 'resourceType', + label: 'Resource Type', + type: 'select', + operator: 'eq', + options: [ + { label: 'Project', value: 'project' }, + { label: 'Registration', value: 'registration' }, + ], + }, + { + key: 'creator', + label: 'Creator', + type: 'select', + operator: 'eq', + hasOptions: true, + }, + { + key: 'accessService', + label: 'Access Service', + type: 'select', + operator: 'eq', + // No options - should not be visible + }, + ]; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ReusableFilterComponent, NoopAnimationsModule], + }).compileComponents(); + + fixture = TestBed.createComponent(ReusableFilterComponent); + component = fixture.componentInstance; + componentRef = fixture.componentRef; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('Component Initialization', () => { + it('should have default input values', () => { + expect(component.filters()).toEqual([]); + expect(component.selectedValues()).toEqual({}); + expect(component.isLoading()).toBe(false); + expect(component.showEmptyState()).toBe(true); + }); + + it('should have access to FILTER_PLACEHOLDERS constant', () => { + expect(component.FILTER_PLACEHOLDERS).toBe(FILTER_PLACEHOLDERS); + }); + + it('should initialize with empty expandedFilters signal', () => { + expect(component['expandedFilters']()).toEqual(new Set()); + }); + }); + + describe('Loading State', () => { + beforeEach(() => { + componentRef.setInput('isLoading', true); + fixture.detectChanges(); + }); + + it('should display loading state when isLoading is true', () => { + const loadingElement = fixture.debugElement.query(By.css('.text-center.text-gray-500 p')); + expect(loadingElement).toBeTruthy(); + expect(loadingElement.nativeElement.textContent.trim()).toBe('Loading filters...'); + }); + + it('should not display filters or empty state when loading', () => { + const accordion = fixture.debugElement.query(By.css('p-accordion')); + const emptyState = fixture.debugElement.query(By.css('.text-center.text-gray-500.py-4')); + + expect(accordion).toBeFalsy(); + expect(emptyState).toBeFalsy(); + }); + }); + + describe('Empty State', () => { + beforeEach(() => { + componentRef.setInput('filters', []); + componentRef.setInput('showEmptyState', true); + fixture.detectChanges(); + }); + + it('should display empty state when no filters and showEmptyState is true', () => { + const emptyState = fixture.debugElement.query(By.css('.text-center.text-gray-500.py-4 p')); + expect(emptyState).toBeTruthy(); + expect(emptyState.nativeElement.textContent.trim()).toBe('No filters available'); + }); + + it('should not display empty state when showEmptyState is false', () => { + componentRef.setInput('showEmptyState', false); + fixture.detectChanges(); + + const emptyState = fixture.debugElement.query(By.css('.text-center.text-gray-500.py-4')); + expect(emptyState).toBeFalsy(); + }); + }); + + describe('Filters Display', () => { + beforeEach(() => { + componentRef.setInput('filters', mockFilters); + componentRef.setInput('selectedValues', { + subject: 'psychology', + creator: 'John Doe', + }); + fixture.detectChanges(); + }); + + it('should display accordion when filters are visible', () => { + const accordion = fixture.debugElement.query(By.css('p-accordion')); + expect(accordion).toBeTruthy(); + }); + + it('should display visible filters in accordion panels', () => { + const panels = fixture.debugElement.queryAll(By.css('p-accordion-panel')); + // Should show subject, resourceType, and creator (accessService has no options) + expect(panels.length).toBe(3); + }); + + it('should display correct filter labels', () => { + const headers = fixture.debugElement.queryAll(By.css('p-accordion-header')); + const headerTexts = headers.map((h) => h.nativeElement.textContent.trim()); + + expect(headerTexts).toContain('Subject'); + expect(headerTexts).toContain('Resource Type'); + expect(headerTexts).toContain('Creator'); + }); + }); + + describe('shouldShowFilter method', () => { + it('should return false for null or undefined filter', () => { + expect(component.shouldShowFilter(null as unknown as DiscoverableFilter)).toBe(false); + expect(component.shouldShowFilter(undefined as unknown as DiscoverableFilter)).toBe(false); + }); + + it('should return false for filter without key', () => { + const filter = { label: 'Test' } as DiscoverableFilter; + expect(component.shouldShowFilter(filter)).toBe(false); + }); + + it('should return true for resourceType/accessService only if they have options', () => { + const filterWithOptions = { + key: 'resourceType', + options: [{ label: 'Test', value: 'test' }], + } as DiscoverableFilter; + const filterWithoutOptions = { key: 'resourceType' } as DiscoverableFilter; + + expect(component.shouldShowFilter(filterWithOptions)).toBe(true); + expect(component.shouldShowFilter(filterWithoutOptions)).toBe(false); + }); + + it('should return true for filters with result count', () => { + const filter = { key: 'subject', resultCount: 10 } as DiscoverableFilter; + expect(component.shouldShowFilter(filter)).toBe(true); + }); + + it('should return true for filters with options', () => { + const filter = { key: 'subject', options: [{ label: 'Test', value: 'test' }] } as DiscoverableFilter; + expect(component.shouldShowFilter(filter)).toBe(true); + }); + + it('should return true for filters with hasOptions flag', () => { + const filter = { key: 'subject', hasOptions: true } as DiscoverableFilter; + expect(component.shouldShowFilter(filter)).toBe(true); + }); + + it('should return true for filters with selected values', () => { + const filter: DiscoverableFilter = { + key: 'subject', + label: 'Subject', + type: 'select', + operator: 'eq', + selectedValues: [{ label: 'Test', value: 'test' }], + }; + expect(component.shouldShowFilter(filter)).toBe(true); + }); + }); + + describe('Computed Properties', () => { + it('should compute hasFilters correctly', () => { + componentRef.setInput('filters', []); + expect(component.hasFilters()).toBe(false); + + componentRef.setInput('filters', mockFilters); + expect(component.hasFilters()).toBe(true); + }); + + it('should compute visibleFilters correctly', () => { + componentRef.setInput('filters', mockFilters); + const visible = component.visibleFilters(); + + // Should exclude accessService (no options) + expect(visible.length).toBe(3); + expect(visible.map((f) => f.key)).toEqual(['subject', 'resourceType', 'creator']); + }); + + it('should compute hasVisibleFilters correctly', () => { + componentRef.setInput('filters', []); + expect(component.hasVisibleFilters()).toBe(false); + + componentRef.setInput('filters', mockFilters); + expect(component.hasVisibleFilters()).toBe(true); + }); + }); + + describe('Event Handling', () => { + beforeEach(() => { + componentRef.setInput('filters', mockFilters); + fixture.detectChanges(); + }); + + it('should emit loadFilterOptions when accordion is toggled and filter needs options', () => { + spyOn(component.loadFilterOptions, 'emit'); + + // Mock a filter that has hasOptions but no options loaded + const filterNeedingOptions: DiscoverableFilter = { + key: 'creator', + label: 'Creator', + type: 'select', + operator: 'eq', + hasOptions: true, + }; + componentRef.setInput('filters', [filterNeedingOptions]); + fixture.detectChanges(); + + component.onAccordionToggle('creator'); + + expect(component.loadFilterOptions.emit).toHaveBeenCalledWith({ + filterType: 'creator', + filter: filterNeedingOptions, + }); + }); + + it('should not emit loadFilterOptions when filter already has options', () => { + spyOn(component.loadFilterOptions, 'emit'); + + component.onAccordionToggle('subject'); + + expect(component.loadFilterOptions.emit).not.toHaveBeenCalled(); + }); + + it('should emit filterValueChanged when filter value changes', () => { + spyOn(component.filterValueChanged, 'emit'); + + component.onFilterChanged('subject', 'biology'); + + expect(component.filterValueChanged.emit).toHaveBeenCalledWith({ + filterType: 'subject', + value: 'biology', + }); + }); + + it('should handle array filterKey in onAccordionToggle', () => { + spyOn(component.loadFilterOptions, 'emit'); + + component.onAccordionToggle(['subject', 'other']); + + // Should use first element of array + expect(component['expandedFilters']().has('subject')).toBe(true); + }); + + it('should handle empty filterKey in onAccordionToggle', () => { + const initialExpanded = new Set(component['expandedFilters']()); + + component.onAccordionToggle(''); + component.onAccordionToggle(null as unknown as string); + + expect(component['expandedFilters']()).toEqual(initialExpanded); + }); + }); + + describe('Helper Methods', () => { + const testFilter: DiscoverableFilter = { + key: 'subject', + label: 'Subject', + type: 'select', + operator: 'eq', + description: 'Test description', + helpLink: 'https://help.test.com', + helpLinkText: 'Custom help text', + resultCount: 42, + options: [{ label: 'Test Option', value: 'test' }], + isLoading: true, + }; + + it('should return correct filter options', () => { + expect(component.getFilterOptions(testFilter)).toEqual(testFilter.options || []); + expect(component.getFilterOptions({} as DiscoverableFilter)).toEqual([]); + }); + + it('should return correct loading state', () => { + expect(component.isFilterLoading(testFilter)).toBe(true); + expect(component.isFilterLoading({} as DiscoverableFilter)).toBe(false); + }); + + it('should return correct selected value', () => { + componentRef.setInput('selectedValues', { subject: 'psychology' }); + + expect(component.getSelectedValue('subject')).toBe('psychology'); + expect(component.getSelectedValue('nonexistent')).toBe(null); + }); + + it('should return correct filter placeholder', () => { + expect(component.getFilterPlaceholder('subject')).toBe('Select subject'); + expect(component.getFilterPlaceholder('unknown')).toBe('Search...'); + }); + + it('should return correct filter editability', () => { + expect(component.isFilterEditable('subject')).toBe(true); + expect(component.isFilterEditable('affiliation')).toBe(false); + expect(component.isFilterEditable('unknown')).toBe(true); // default + }); + + it('should return correct filter description', () => { + expect(component.getFilterDescription(testFilter)).toBe('Test description'); + expect(component.getFilterDescription({} as DiscoverableFilter)).toBe(null); + }); + + it('should return correct filter help link', () => { + expect(component.getFilterHelpLink(testFilter)).toBe('https://help.test.com'); + expect(component.getFilterHelpLink({} as DiscoverableFilter)).toBe(null); + }); + + it('should return correct filter help link text', () => { + expect(component.getFilterHelpLinkText(testFilter)).toBe('Custom help text'); + expect(component.getFilterHelpLinkText({} as DiscoverableFilter)).toBe('Learn more'); + }); + + it('should return correct filter label with fallbacks', () => { + expect(component.getFilterLabel(testFilter)).toBe('Subject'); + expect(component.getFilterLabel({ key: 'test' } as DiscoverableFilter)).toBe('test'); + expect(component.getFilterLabel({} as DiscoverableFilter)).toBe('Filter'); + }); + + it('should determine filter content correctly', () => { + expect(component.hasFilterContent(testFilter)).toBe(true); + + const emptyFilter = {} as DiscoverableFilter; + expect(component.hasFilterContent(emptyFilter)).toBe(false); + + const filterWithOnlyHasOptions = { hasOptions: true } as DiscoverableFilter; + expect(component.hasFilterContent(filterWithOnlyHasOptions)).toBe(true); + }); + }); + + describe('Expanded Filters State', () => { + beforeEach(() => { + componentRef.setInput('filters', mockFilters); + fixture.detectChanges(); + }); + + it('should toggle expanded state correctly', () => { + expect(component['expandedFilters']().has('subject')).toBe(false); + + component.onAccordionToggle('subject'); + expect(component['expandedFilters']().has('subject')).toBe(true); + + component.onAccordionToggle('subject'); + expect(component['expandedFilters']().has('subject')).toBe(false); + }); + + it('should handle multiple expanded filters', () => { + component.onAccordionToggle('subject'); + component.onAccordionToggle('creator'); + + expect(component['expandedFilters']().has('subject')).toBe(true); + expect(component['expandedFilters']().has('creator')).toBe(true); + }); + }); + + describe('Integration Tests', () => { + beforeEach(() => { + componentRef.setInput('filters', mockFilters); + componentRef.setInput('selectedValues', { subject: 'psychology' }); + fixture.detectChanges(); + }); + + it('should pass correct props to generic filter components', () => { + const genericFilters = fixture.debugElement.queryAll(By.css('osf-generic-filter')); + expect(genericFilters.length).toBeGreaterThan(0); + + // Check if generic filter receives correct inputs + const subjectFilter = genericFilters.find((gf) => gf.componentInstance.filterType === 'subject'); + + if (subjectFilter) { + expect(subjectFilter.componentInstance.selectedValue).toBe('psychology'); + expect(subjectFilter.componentInstance.placeholder).toBe('Select subject'); + expect(subjectFilter.componentInstance.editable).toBe(true); + } + }); + + it('should handle filter value change events from generic filter', () => { + spyOn(component, 'onFilterChanged'); + + const genericFilter = fixture.debugElement.query(By.css('osf-generic-filter')); + if (genericFilter) { + genericFilter.componentInstance.valueChanged.emit('new-value'); + + expect(component.onFilterChanged).toHaveBeenCalled(); + } + }); + }); + + describe('Edge Cases', () => { + it('should handle malformed filter data gracefully', () => { + const malformedFilters = [ + null, + undefined, + { key: null }, + { key: '', label: '' }, + { key: 'valid', options: null }, + ] as unknown as DiscoverableFilter[]; + + expect(() => { + componentRef.setInput('filters', malformedFilters); + fixture.detectChanges(); + }).not.toThrow(); + }); + + it('should handle empty selected values', () => { + componentRef.setInput('selectedValues', {}); + componentRef.setInput('filters', mockFilters); + fixture.detectChanges(); + + expect(component.getSelectedValue('subject')).toBe(null); + }); + + it('should handle filters without required properties', () => { + const minimalFilter = { key: 'minimal' } as DiscoverableFilter; + + expect(component.getFilterLabel(minimalFilter)).toBe('minimal'); + expect(component.getFilterDescription(minimalFilter)).toBe(null); + expect(component.hasFilterContent(minimalFilter)).toBe(false); + }); + }); +}); diff --git a/src/app/shared/components/reusable-filter/reusable-filter.component.ts b/src/app/shared/components/reusable-filter/reusable-filter.component.ts new file mode 100644 index 00000000..f2b16520 --- /dev/null +++ b/src/app/shared/components/reusable-filter/reusable-filter.component.ts @@ -0,0 +1,144 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Accordion, AccordionContent, AccordionHeader, AccordionPanel } from 'primeng/accordion'; +import { AutoCompleteModule } from 'primeng/autocomplete'; + +import { ChangeDetectionStrategy, Component, computed, input, output, signal } from '@angular/core'; +import { ReactiveFormsModule } from '@angular/forms'; + +import { LoadingSpinnerComponent } from '@shared/components'; +import { FILTER_PLACEHOLDERS } from '@shared/constants/filter-placeholders'; +import { ReusableFilterType } from '@shared/enums'; +import { DiscoverableFilter, SelectOption } from '@shared/models'; + +import { GenericFilterComponent } from '../generic-filter/generic-filter.component'; + +@Component({ + selector: 'osf-reusable-filters', + imports: [ + Accordion, + AccordionContent, + AccordionHeader, + AccordionPanel, + AutoCompleteModule, + ReactiveFormsModule, + GenericFilterComponent, + TranslatePipe, + LoadingSpinnerComponent, + ], + templateUrl: './reusable-filter.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ReusableFilterComponent { + filters = input([]); + selectedValues = input>({}); + isLoading = input(false); + showEmptyState = input(true); + + loadFilterOptions = output<{ filterType: string; filter: DiscoverableFilter }>(); + filterValueChanged = output<{ filterType: string; value: string | null }>(); + + private readonly expandedFilters = signal>(new Set()); + + readonly FILTER_PLACEHOLDERS = FILTER_PLACEHOLDERS; + + readonly hasFilters = computed(() => { + const filterList = this.filters(); + return filterList && filterList.length > 0; + }); + + readonly visibleFilters = computed(() => { + return this.filters().filter((filter) => this.shouldShowFilter(filter)); + }); + + readonly hasVisibleFilters = computed(() => { + return this.visibleFilters().length > 0; + }); + + shouldShowFilter(filter: DiscoverableFilter): boolean { + if (!filter || !filter.key) return false; + + if (filter.key === 'resourceType' || filter.key === 'accessService') { + return Boolean(filter.options && filter.options.length > 0); + } + + return Boolean( + (filter.resultCount && filter.resultCount > 0) || + (filter.options && filter.options.length > 0) || + filter.hasOptions || + (filter.selectedValues && filter.selectedValues.length > 0) + ); + } + + onAccordionToggle(filterKey: string | number | string[] | number[]): void { + if (!filterKey) return; + + const key = Array.isArray(filterKey) ? filterKey[0]?.toString() : filterKey.toString(); + const selectedFilter = this.filters().find((filter) => filter.key === key); + + if (selectedFilter) { + this.expandedFilters.update((expanded) => { + const newExpanded = new Set(expanded); + if (newExpanded.has(key)) { + newExpanded.delete(key); + } else { + newExpanded.add(key); + } + return newExpanded; + }); + + if (!selectedFilter.options?.length && selectedFilter.hasOptions) { + this.loadFilterOptions.emit({ + filterType: key as ReusableFilterType, + filter: selectedFilter, + }); + } + } + } + + onFilterChanged(filterType: string, value: string | null): void { + this.filterValueChanged.emit({ filterType, value }); + } + + getFilterOptions(filter: DiscoverableFilter): SelectOption[] { + return filter.options || []; + } + + isFilterLoading(filter: DiscoverableFilter): boolean { + return filter.isLoading || false; + } + + getSelectedValue(filterKey: string): string | null { + return this.selectedValues()[filterKey] || null; + } + + getFilterPlaceholder(filterKey: string): string { + return this.FILTER_PLACEHOLDERS[filterKey] || ''; + } + + getFilterDescription(filter: DiscoverableFilter): string | null { + return filter.description || null; + } + + getFilterHelpLink(filter: DiscoverableFilter): string | null { + return filter.helpLink || null; + } + + getFilterHelpLinkText(filter: DiscoverableFilter): string | null { + return filter.helpLinkText || ''; + } + + getFilterLabel(filter: DiscoverableFilter): string { + return filter.label || filter.key || ''; + } + + hasFilterContent(filter: DiscoverableFilter): boolean { + return !!( + filter.description || + filter.helpLink || + filter.resultCount || + filter.options?.length || + filter.hasOptions + ); + } +} diff --git a/src/app/shared/components/search-results-container/search-results-container.component.html b/src/app/shared/components/search-results-container/search-results-container.component.html new file mode 100644 index 00000000..7e98a712 --- /dev/null +++ b/src/app/shared/components/search-results-container/search-results-container.component.html @@ -0,0 +1,129 @@ +
+
+ + +

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

+
+ +
+ + + + + @if (isAnyFilterOptions()) { + filter by + } + sort by +
+
+ +@if (isFiltersOpen()) { +
+ +
+} @else if (isSortingOpen()) { +
+ @for (option of searchSortingOptions; track option.value) { +
+ {{ option.label }} +
+ } +
+} @else { + @if (hasSelectedValues()) { +
+ +
+ } +} + +
+ + + + +
+ @if (items.length > 0) { + @for (item of items; track item.id) { + + } + +
+ @if (first() && prev()) { + + } + + + + + + +
+ } +
+
+
+
diff --git a/src/app/shared/components/search-results-container/search-results-container.component.scss b/src/app/shared/components/search-results-container/search-results-container.component.scss new file mode 100644 index 00000000..b9d7f895 --- /dev/null +++ b/src/app/shared/components/search-results-container/search-results-container.component.scss @@ -0,0 +1,16 @@ +.result-count { + color: var(--pr-blue-1); +} + +.sort-card { + &:hover { + background-color: var(--grey-3); + border-color: var(--pr-blue-1); + } + + &.card-selected { + background-color: var(--pr-blue-1); + color: var(--white); + border-color: var(--pr-blue-1); + } +} diff --git a/src/app/shared/components/search-results-container/search-results-container.component.spec.ts b/src/app/shared/components/search-results-container/search-results-container.component.spec.ts new file mode 100644 index 00000000..b6629ebf --- /dev/null +++ b/src/app/shared/components/search-results-container/search-results-container.component.spec.ts @@ -0,0 +1,475 @@ +import { ComponentRef } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { SEARCH_TAB_OPTIONS, searchSortingOptions } from '@shared/constants'; +import { ResourceTab } from '@shared/enums'; +import { Resource } from '@shared/models'; + +import { SearchResultsContainerComponent } from './search-results-container.component'; + +describe('SearchResultsContainerComponent', () => { + let component: SearchResultsContainerComponent; + let fixture: ComponentFixture; + let componentRef: ComponentRef; + + const mockResources: Resource[] = [ + { + id: '1', + title: 'Test Resource 1', + description: 'Test Description 1', + type: 'project', + url: 'http://test1.com', + contributors: [], + tags: [], + dateCreated: new Date('2023-01-01'), + dateModified: new Date('2023-01-01'), + }, + { + id: '2', + title: 'Test Resource 2', + description: 'Test Description 2', + type: 'registration', + url: 'http://test2.com', + contributors: [], + tags: [], + dateCreated: new Date('2023-01-02'), + dateModified: new Date('2023-01-02'), + }, + ]; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SearchResultsContainerComponent, NoopAnimationsModule], + }).compileComponents(); + + fixture = TestBed.createComponent(SearchResultsContainerComponent); + component = fixture.componentInstance; + componentRef = fixture.componentRef; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('Component Initialization', () => { + it('should have default input values', () => { + expect(component.resources()).toEqual([]); + expect(component.searchCount()).toBe(0); + expect(component.selectedSort()).toBe(''); + expect(component.selectedTab()).toBe(ResourceTab.All); + expect(component.selectedValues()).toEqual({}); + expect(component.first()).toBeNull(); + expect(component.prev()).toBeNull(); + expect(component.next()).toBeNull(); + expect(component.isFiltersOpen()).toBe(false); + expect(component.isSortingOpen()).toBe(false); + }); + + it('should have access to constants', () => { + expect(component['searchSortingOptions']).toBe(searchSortingOptions); + expect(component['ResourceTab']).toBe(ResourceTab); + expect(component['tabsOptions']).toBe(SEARCH_TAB_OPTIONS); + }); + }); + + describe('Computed Properties', () => { + it('should compute hasSelectedValues correctly when no values are selected', () => { + componentRef.setInput('selectedValues', {}); + expect(component['hasSelectedValues']()).toBe(false); + }); + + it('should compute hasSelectedValues correctly when values are selected', () => { + componentRef.setInput('selectedValues', { subject: 'psychology', type: 'project' }); + expect(component['hasSelectedValues']()).toBe(true); + }); + + it('should compute hasSelectedValues correctly when some values are null or empty', () => { + componentRef.setInput('selectedValues', { subject: null, type: '', category: 'science' }); + expect(component['hasSelectedValues']()).toBe(true); + }); + + it('should compute hasSelectedValues correctly when all values are null or empty', () => { + componentRef.setInput('selectedValues', { subject: null, type: '', category: '' }); + expect(component['hasSelectedValues']()).toBe(false); + }); + + it('should compute hasFilters correctly', () => { + expect(component['hasFilters']()).toBe(true); + }); + }); + + describe('Display Logic', () => { + beforeEach(() => { + componentRef.setInput('resources', mockResources); + componentRef.setInput('searchCount', 2); + componentRef.setInput('selectedSort', 'relevance'); + componentRef.setInput('selectedTab', ResourceTab.All); + fixture.detectChanges(); + }); + + it('should display correct search count when count is normal', () => { + const countElement = fixture.debugElement.query(By.css('h3')); + expect(countElement.nativeElement.textContent.trim()).toBe('2 results'); + }); + + it('should display 10000+ when search count is above 10000', () => { + componentRef.setInput('searchCount', 15000); + fixture.detectChanges(); + + const countElement = fixture.debugElement.query(By.css('h3')); + expect(countElement.nativeElement.textContent.trim()).toBe('10 000+ results'); + }); + + it('should display 0 results when search count is 0', () => { + componentRef.setInput('searchCount', 0); + fixture.detectChanges(); + + const countElement = fixture.debugElement.query(By.css('h3')); + expect(countElement.nativeElement.textContent.trim()).toBe('0 results'); + }); + + it('should display mobile dropdown when on mobile', () => { + const mobileDropdown = fixture.debugElement.query(By.css('p-select.text-center.inline-flex.md\\:hidden')); + expect(mobileDropdown).toBeTruthy(); + }); + + it('should display desktop sorting dropdown', () => { + const desktopDropdown = fixture.debugElement.query(By.css('p-select.no-border-dropdown')); + expect(desktopDropdown).toBeTruthy(); + }); + + it('should show filter chips when hasSelectedValues is true', () => { + componentRef.setInput('selectedValues', { subject: 'psychology' }); + fixture.detectChanges(); + + const filterChipsSlot = fixture.debugElement.query(By.css('[slot="filter-chips"]')); + expect(filterChipsSlot).toBeTruthy(); + }); + + it('should not show filter chips when hasSelectedValues is false', () => { + componentRef.setInput('selectedValues', {}); + fixture.detectChanges(); + + const filterChipsContainer = fixture.debugElement.query(By.css('.mb-3')); + expect(filterChipsContainer).toBeFalsy(); + }); + }); + + describe('Conditional Display States', () => { + it('should display filters when isFiltersOpen is true', () => { + componentRef.setInput('isFiltersOpen', true); + fixture.detectChanges(); + + const filtersContainer = fixture.debugElement.query(By.css('.filter-full-size')); + expect(filtersContainer).toBeTruthy(); + }); + + it('should display sorting options when isSortingOpen is true', () => { + componentRef.setInput('isSortingOpen', true); + fixture.detectChanges(); + + const sortingContainer = fixture.debugElement.query(By.css('.flex.flex-column.p-5.pt-1.row-gap-3')); + expect(sortingContainer).toBeTruthy(); + }); + + it('should display sorting cards when isSortingOpen is true', () => { + componentRef.setInput('isSortingOpen', true); + fixture.detectChanges(); + + const sortCards = fixture.debugElement.queryAll(By.css('.sort-card')); + expect(sortCards.length).toBe(searchSortingOptions.length); + }); + + it('should highlight selected sorting option', () => { + componentRef.setInput('isSortingOpen', true); + componentRef.setInput('selectedSort', 'relevance'); + fixture.detectChanges(); + + const selectedCard = fixture.debugElement.query(By.css('.sort-card.card-selected')); + expect(selectedCard).toBeTruthy(); + }); + + it('should display main content when neither filters nor sorting are open', () => { + componentRef.setInput('isFiltersOpen', false); + componentRef.setInput('isSortingOpen', false); + componentRef.setInput('resources', mockResources); + fixture.detectChanges(); + + const dataView = fixture.debugElement.query(By.css('p-dataView')); + expect(dataView).toBeTruthy(); + }); + }); + + describe('Method Testing', () => { + it('should emit sortChanged when selectSort is called', () => { + spyOn(component.sortChanged, 'emit'); + + component.selectSort('relevance'); + + expect(component.sortChanged.emit).toHaveBeenCalledWith('relevance'); + }); + + it('should emit tabChanged when selectTab is called', () => { + spyOn(component.tabChanged, 'emit'); + + component.selectTab(ResourceTab.Projects); + + expect(component.tabChanged.emit).toHaveBeenCalledWith(ResourceTab.Projects); + }); + + it('should emit pageChanged when switchPage is called with valid link', () => { + spyOn(component.pageChanged, 'emit'); + + component.switchPage('http://example.com/page2'); + + expect(component.pageChanged.emit).toHaveBeenCalledWith('http://example.com/page2'); + }); + + it('should not emit pageChanged when switchPage is called with null', () => { + spyOn(component.pageChanged, 'emit'); + + component.switchPage(null); + + expect(component.pageChanged.emit).not.toHaveBeenCalled(); + }); + + it('should emit filtersToggled when openFilters is called', () => { + spyOn(component.filtersToggled, 'emit'); + + component.openFilters(); + + expect(component.filtersToggled.emit).toHaveBeenCalled(); + }); + + it('should emit sortingToggled when openSorting is called', () => { + spyOn(component.sortingToggled, 'emit'); + + component.openSorting(); + + expect(component.sortingToggled.emit).toHaveBeenCalled(); + }); + + it('should return true for isAnyFilterOptions', () => { + expect(component.isAnyFilterOptions()).toBe(true); + }); + }); + + describe('Pagination', () => { + beforeEach(() => { + componentRef.setInput('resources', mockResources); + componentRef.setInput('first', 'http://example.com/page1'); + componentRef.setInput('prev', 'http://example.com/prev'); + componentRef.setInput('next', 'http://example.com/next'); + fixture.detectChanges(); + }); + + it('should display pagination buttons when navigation links are available', () => { + const paginationButtons = fixture.debugElement.queryAll(By.css('p-button')); + expect(paginationButtons.length).toBeGreaterThan(0); + }); + + it('should handle first page button click', () => { + spyOn(component, 'switchPage'); + + const firstButton = fixture.debugElement.query(By.css('p-button[icon="fas fa-angles-left"]')); + firstButton.nativeElement.click(); + + expect(component.switchPage).toHaveBeenCalledWith('http://example.com/page1'); + }); + + it('should handle previous page button click', () => { + spyOn(component, 'switchPage'); + + const prevButton = fixture.debugElement.query(By.css('p-button[icon="fas fa-angle-left"]')); + prevButton.nativeElement.click(); + + expect(component.switchPage).toHaveBeenCalledWith('http://example.com/prev'); + }); + + it('should handle next page button click', () => { + spyOn(component, 'switchPage'); + + const nextButton = fixture.debugElement.query(By.css('p-button[icon="fas fa-angle-right"]')); + nextButton.nativeElement.click(); + + expect(component.switchPage).toHaveBeenCalledWith('http://example.com/next'); + }); + + it('should disable previous button when prev is null', () => { + componentRef.setInput('prev', null); + fixture.detectChanges(); + + const prevButton = fixture.debugElement.query(By.css('p-button[icon="fas fa-angle-left"]')); + expect(prevButton.nativeElement.disabled).toBe(true); + }); + + it('should disable next button when next is null', () => { + componentRef.setInput('next', null); + fixture.detectChanges(); + + const nextButton = fixture.debugElement.query(By.css('p-button[icon="fas fa-angle-right"]')); + expect(nextButton.nativeElement.disabled).toBe(true); + }); + }); + + describe('Event Handling', () => { + beforeEach(() => { + componentRef.setInput('resources', mockResources); + fixture.detectChanges(); + }); + + it('should handle sort selection from dropdown', () => { + spyOn(component, 'selectSort'); + + const sortDropdown = fixture.debugElement.query(By.css('p-select.no-border-dropdown')); + sortDropdown.triggerEventHandler('ngModelChange', 'date'); + + expect(component.selectSort).toHaveBeenCalledWith('date'); + }); + + it('should handle tab selection from mobile dropdown', () => { + spyOn(component, 'selectTab'); + + const tabDropdown = fixture.debugElement.query(By.css('p-select.text-center.inline-flex.md\\:hidden')); + tabDropdown.triggerEventHandler('ngModelChange', ResourceTab.Projects); + + expect(component.selectTab).toHaveBeenCalledWith(ResourceTab.Projects); + }); + + it('should handle filter icon click', () => { + spyOn(component, 'openFilters'); + + const filterIcon = fixture.debugElement.query(By.css('img[alt="filter by"]')); + filterIcon.nativeElement.click(); + + expect(component.openFilters).toHaveBeenCalled(); + }); + + it('should handle sort icon click', () => { + spyOn(component, 'openSorting'); + + const sortIcon = fixture.debugElement.query(By.css('img[alt="sort by"]')); + sortIcon.nativeElement.click(); + + expect(component.openSorting).toHaveBeenCalled(); + }); + + it('should handle filter icon keyboard enter', () => { + spyOn(component, 'openFilters'); + + const filterIcon = fixture.debugElement.query(By.css('img[alt="filter by"]')); + filterIcon.triggerEventHandler('keydown.enter', {}); + + expect(component.openFilters).toHaveBeenCalled(); + }); + + it('should handle sort icon keyboard enter', () => { + spyOn(component, 'openSorting'); + + const sortIcon = fixture.debugElement.query(By.css('img[alt="sort by"]')); + sortIcon.triggerEventHandler('keydown.enter', {}); + + expect(component.openSorting).toHaveBeenCalled(); + }); + }); + + describe('Sorting Card Interaction', () => { + beforeEach(() => { + componentRef.setInput('isSortingOpen', true); + componentRef.setInput('selectedSort', 'relevance'); + fixture.detectChanges(); + }); + + it('should handle sort card click', () => { + spyOn(component, 'selectSort'); + + const sortCard = fixture.debugElement.query(By.css('.sort-card')); + sortCard.nativeElement.click(); + + expect(component.selectSort).toHaveBeenCalledWith(searchSortingOptions[0].value); + }); + + it('should handle sort card keyboard enter', () => { + spyOn(component, 'selectSort'); + + const sortCard = fixture.debugElement.query(By.css('.sort-card')); + sortCard.triggerEventHandler('keydown.enter', {}); + + expect(component.selectSort).toHaveBeenCalledWith(searchSortingOptions[0].value); + }); + }); + + describe('Resource Display', () => { + beforeEach(() => { + componentRef.setInput('resources', mockResources); + fixture.detectChanges(); + }); + + it('should display resources in data view', () => { + const dataView = fixture.debugElement.query(By.css('p-dataView')); + expect(dataView).toBeTruthy(); + expect(dataView.componentInstance.value).toEqual(mockResources); + }); + + it('should set correct rows per page', () => { + const dataView = fixture.debugElement.query(By.css('p-dataView')); + expect(dataView.componentInstance.rows).toBe(10); + }); + }); + + describe('Accessibility', () => { + beforeEach(() => { + fixture.detectChanges(); + }); + + it('should have proper tabindex on interactive elements', () => { + const filterIcon = fixture.debugElement.query(By.css('img[alt="filter by"]')); + const sortIcon = fixture.debugElement.query(By.css('img[alt="sort by"]')); + + expect(filterIcon.nativeElement.getAttribute('tabindex')).toBe('0'); + expect(sortIcon.nativeElement.getAttribute('tabindex')).toBe('0'); + }); + + it('should have proper role attributes on interactive elements', () => { + const filterIcon = fixture.debugElement.query(By.css('img[alt="filter by"]')); + const sortIcon = fixture.debugElement.query(By.css('img[alt="sort by"]')); + + expect(filterIcon.nativeElement.getAttribute('role')).toBe('button'); + expect(sortIcon.nativeElement.getAttribute('role')).toBe('button'); + }); + + it('should have proper alt text on icons', () => { + const filterIcon = fixture.debugElement.query(By.css('img[alt="filter by"]')); + const sortIcon = fixture.debugElement.query(By.css('img[alt="sort by"]')); + + expect(filterIcon.nativeElement.getAttribute('alt')).toBe('filter by'); + expect(sortIcon.nativeElement.getAttribute('alt')).toBe('sort by'); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty resources array', () => { + componentRef.setInput('resources', []); + fixture.detectChanges(); + + const dataView = fixture.debugElement.query(By.css('p-dataView')); + expect(dataView.componentInstance.value).toEqual([]); + }); + + it('should handle undefined selected values', () => { + componentRef.setInput('selectedValues', undefined); + expect(() => component['hasSelectedValues']()).not.toThrow(); + }); + + it('should handle null navigation links', () => { + componentRef.setInput('first', null); + componentRef.setInput('prev', null); + componentRef.setInput('next', null); + fixture.detectChanges(); + + expect(() => fixture.detectChanges()).not.toThrow(); + }); + }); +}); diff --git a/src/app/shared/components/search-results-container/search-results-container.component.ts b/src/app/shared/components/search-results-container/search-results-container.component.ts new file mode 100644 index 00000000..070ae6d3 --- /dev/null +++ b/src/app/shared/components/search-results-container/search-results-container.component.ts @@ -0,0 +1,92 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { DataView } from 'primeng/dataview'; +import { Select } from 'primeng/select'; + +import { NgOptimizedImage } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +import { Primitive } from '@core/helpers'; +import { SEARCH_TAB_OPTIONS, searchSortingOptions } from '@shared/constants'; +import { ResourceTab } from '@shared/enums'; +import { Resource } from '@shared/models'; + +import { ResourceCardComponent } from '../resource-card/resource-card.component'; +import { SelectComponent } from '../select/select.component'; + +@Component({ + selector: 'osf-search-results-container', + imports: [ + FormsModule, + NgOptimizedImage, + Button, + DataView, + Select, + ResourceCardComponent, + TranslatePipe, + SelectComponent, + ], + templateUrl: './search-results-container.component.html', + styleUrl: './search-results-container.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SearchResultsContainerComponent { + resources = input([]); + searchCount = input(0); + selectedSort = input(''); + selectedTab = input(ResourceTab.All); + selectedValues = input>({}); + first = input(null); + prev = input(null); + next = input(null); + isFiltersOpen = input(false); + isSortingOpen = input(false); + + sortChanged = output(); + tabChanged = output(); + pageChanged = output(); + filtersToggled = output(); + sortingToggled = output(); + + protected readonly searchSortingOptions = searchSortingOptions; + protected readonly ResourceTab = ResourceTab; + + protected readonly tabsOptions = SEARCH_TAB_OPTIONS; + + protected readonly hasSelectedValues = computed(() => { + const values = this.selectedValues(); + return Object.values(values).some((value) => value !== null && value !== ''); + }); + + protected readonly hasFilters = computed(() => { + return true; + }); + + selectSort(value: string): void { + this.sortChanged.emit(value); + } + + selectTab(value?: ResourceTab): void { + this.tabChanged.emit((value ? value : this.selectedTab()) as ResourceTab); + } + + switchPage(link: string | null): void { + if (link != null) { + this.pageChanged.emit(link); + } + } + + openFilters(): void { + this.filtersToggled.emit(); + } + + openSorting(): void { + this.sortingToggled.emit(); + } + + isAnyFilterOptions(): boolean { + return this.hasFilters(); + } +} diff --git a/src/app/shared/constants/filter-placeholders.ts b/src/app/shared/constants/filter-placeholders.ts new file mode 100644 index 00000000..de0ec67e --- /dev/null +++ b/src/app/shared/constants/filter-placeholders.ts @@ -0,0 +1,11 @@ +export const FILTER_PLACEHOLDERS: Record = { + affiliation: 'common.search.filterPlaceholders.affiliation', + subject: 'common.search.filterPlaceholders.subject', + funder: 'common.search.filterPlaceholders.funder', + rights: 'common.search.filterPlaceholders.rights', + publisher: 'common.search.filterPlaceholders.publisher', + isPartOfCollection: 'common.search.filterPlaceholders.isPartOfCollection', + dateCreated: 'common.search.filterPlaceholders.dateCreated', + creator: 'common.search.filterPlaceholders.creator', + resourceType: 'common.search.filterPlaceholders.resourceType', +}; diff --git a/src/app/shared/constants/index.ts b/src/app/shared/constants/index.ts index b7598dcf..2c0a7d77 100644 --- a/src/app/shared/constants/index.ts +++ b/src/app/shared/constants/index.ts @@ -1,6 +1,7 @@ export * from './addon-terms.const'; export * from './addons-category-options.const'; export * from './addons-tab-options.const'; +export * from './filter-placeholders'; export * from './input-limits.const'; export * from './input-validation-messages.const'; export * from './language.const'; diff --git a/src/app/shared/enums/index.ts b/src/app/shared/enums/index.ts index b953b178..5af34c16 100644 --- a/src/app/shared/enums/index.ts +++ b/src/app/shared/enums/index.ts @@ -11,6 +11,7 @@ export * from './get-resources-request-type.enum'; export * from './profile-addons-stepper.enum'; export * from './resource-tab.enum'; export * from './resource-type.enum'; +export * from './reusable-filter-type.enum'; export * from './share-indexing.enum'; export * from './sort-order.enum'; export * from './sort-type.enum'; diff --git a/src/app/shared/enums/reusable-filter-type.enum.ts b/src/app/shared/enums/reusable-filter-type.enum.ts new file mode 100644 index 00000000..7c1dbba8 --- /dev/null +++ b/src/app/shared/enums/reusable-filter-type.enum.ts @@ -0,0 +1,13 @@ +export enum ReusableFilterType { + AFFILIATION = 'affiliation', + ACCESS_SERVICE = 'accessService', + RESOURCE_TYPE = 'resourceType', + SUBJECT = 'subject', + FUNDER = 'funder', + DATE_CREATED = 'dateCreated', + CREATOR = 'creator', + IS_PART_OF_COLLECTION = 'isPartOfCollection', + PUBLISHER = 'publisher', + RIGHTS = 'rights', + RESOURCE_NATURE = 'resourceNature', +} diff --git a/src/app/shared/mappers/filters/filter-option.mapper.ts b/src/app/shared/mappers/filters/filter-option.mapper.ts new file mode 100644 index 00000000..2ef5a1ab --- /dev/null +++ b/src/app/shared/mappers/filters/filter-option.mapper.ts @@ -0,0 +1,15 @@ +import { ApiData } from '@osf/core/models'; +import { FilterOptionAttributes, FilterOptionMetadata, SelectOption } from '@shared/models'; + +export type FilterOptionItem = ApiData; + +export function mapFilterOption(item: FilterOptionItem): SelectOption { + const metadata: FilterOptionMetadata = item.attributes.resourceMetadata; + const name = metadata.name?.[0]?.['@value'] || metadata.title?.[0]?.['@value'] || ''; + const id = metadata['@id']; + + return { + label: name, + value: id, + }; +} diff --git a/src/app/shared/mappers/filters/index.ts b/src/app/shared/mappers/filters/index.ts index 7b93b15d..e062214b 100644 --- a/src/app/shared/mappers/filters/index.ts +++ b/src/app/shared/mappers/filters/index.ts @@ -1,9 +1,11 @@ export * from './creators.mappers'; export * from './date-created.mapper'; +export * from './filter-option.mapper'; export * from './funder.mapper'; export * from './institution.mapper'; export * from './license.mapper'; export * from './part-of-collection.mapper'; export * from './provider.mapper'; export * from './resource-type.mapper'; +export * from './reusable-filter.mapper'; export * from './subject.mapper'; diff --git a/src/app/shared/mappers/filters/reusable-filter.mapper.ts b/src/app/shared/mappers/filters/reusable-filter.mapper.ts new file mode 100644 index 00000000..2a08d29c --- /dev/null +++ b/src/app/shared/mappers/filters/reusable-filter.mapper.ts @@ -0,0 +1,192 @@ +import { ApiData } from '@osf/core/models'; +import { DiscoverableFilter } from '@shared/models'; + +export interface RelatedPropertyPathAttributes { + propertyPathKey: string; + propertyPath: { + '@id': string; + displayLabel: { + '@language': string; + '@value': string; + }[]; + description?: { + '@language': string; + '@value': string; + }[]; + link?: { + '@language': string; + '@value': string; + }[]; + linkText?: { + '@language': string; + '@value': string; + }[]; + resourceType: { + '@id': string; + }[]; + shortFormLabel: { + '@language': string; + '@value': string; + }[]; + }[]; + suggestedFilterOperator: string; + cardSearchResultCount: number; + osfmapPropertyPath: string[]; +} + +export interface AppliedFilter { + propertyPathKey: string; + propertyPathSet: { + '@id': string; + displayLabel?: { + '@language': string; + '@value': string; + }[]; + description?: { + '@language': string; + '@value': string; + }[]; + link?: { + '@language': string; + '@value': string; + }[]; + linkText?: { + '@language': string; + '@value': string; + }[]; + resourceType: { + '@id': string; + }[]; + shortFormLabel: { + '@language': string; + '@value': string; + }[]; + }[][]; + filterValueSet: { + '@id': string; + displayLabel?: { + '@language': string; + '@value': string; + }[]; + resourceType?: { + '@id': string; + }[]; + shortFormLabel?: { + '@language': string; + '@value': string; + }[]; + }[]; + filterType: { + '@id': string; + }; +} + +export type RelatedPropertyPathItem = ApiData; + +export function ReusableFilterMapper(item: RelatedPropertyPathItem): DiscoverableFilter { + const key = item.attributes.propertyPathKey; + const propertyPath = item.attributes.propertyPath?.[0]; + const label = propertyPath?.displayLabel?.[0]?.['@value'] ?? key; + const operator = item.attributes.suggestedFilterOperator ?? 'any-of'; + const description = propertyPath?.description?.[0]?.['@value']; + const helpLink = propertyPath?.link?.[0]?.['@value']; + const helpLinkText = propertyPath?.linkText?.[0]?.['@value']; + + const type: DiscoverableFilter['type'] = key === 'dateCreated' ? 'date' : key === 'creator' ? 'checkbox' : 'select'; + + const shouldLoadOptions = [ + 'subject', + 'rights', + 'resourceNature', + 'affiliation', + 'publisher', + 'funder', + 'isPartOfCollection', + ].includes(key); + + return { + key, + label, + type, + operator, + options: [], + description, + helpLink, + helpLinkText, + resultCount: item.attributes.cardSearchResultCount, + isLoading: false, + isLoaded: false, + hasOptions: shouldLoadOptions, + loadOptionsOnExpand: shouldLoadOptions, + }; +} + +export function AppliedFilterMapper(appliedFilter: AppliedFilter): DiscoverableFilter { + const key = appliedFilter.propertyPathKey; + const propertyPath = appliedFilter.propertyPathSet?.[0]?.[0]; + const label = propertyPath?.displayLabel?.[0]?.['@value'] ?? key; + const operator = appliedFilter.filterType?.['@id']?.replace('trove:', '') ?? 'any-of'; + const description = propertyPath?.description?.[0]?.['@value']; + const helpLink = propertyPath?.link?.[0]?.['@value']; + const helpLinkText = propertyPath?.linkText?.[0]?.['@value']; + + const type: DiscoverableFilter['type'] = key === 'dateCreated' ? 'date' : key === 'creator' ? 'checkbox' : 'select'; + + const shouldLoadOptions = [ + 'subject', + 'rights', + 'resourceNature', + 'affiliation', + 'publisher', + 'funder', + 'isPartOfCollection', + ].includes(key); + + return { + key, + label, + type, + operator, + description, + helpLink, + helpLinkText, + isLoading: false, + hasOptions: shouldLoadOptions, + loadOptionsOnExpand: shouldLoadOptions, + }; +} + +export function CombinedFilterMapper( + appliedFilters: AppliedFilter[] = [], + availableFilters: RelatedPropertyPathItem[] = [] +): DiscoverableFilter[] { + const filterMap = new Map(); + + appliedFilters.forEach((appliedFilter) => { + const filter = AppliedFilterMapper(appliedFilter); + filterMap.set(filter.key, filter); + }); + + availableFilters.forEach((availableFilter) => { + const key = availableFilter.attributes.propertyPathKey; + const existingFilter = filterMap.get(key); + + if (existingFilter) { + existingFilter.resultCount = availableFilter.attributes.cardSearchResultCount; + if (!existingFilter.description) { + existingFilter.description = availableFilter.attributes.propertyPath?.[0]?.description?.[0]?.['@value']; + } + if (!existingFilter.helpLink) { + existingFilter.helpLink = availableFilter.attributes.propertyPath?.[0]?.link?.[0]?.['@value']; + } + if (!existingFilter.helpLinkText) { + existingFilter.helpLinkText = availableFilter.attributes.propertyPath?.[0]?.linkText?.[0]?.['@value']; + } + } else { + const filter = ReusableFilterMapper(availableFilter); + filterMap.set(filter.key, filter); + } + }); + + return Array.from(filterMap.values()); +} diff --git a/src/app/shared/models/index.ts b/src/app/shared/models/index.ts index bebcc60f..b3e84070 100644 --- a/src/app/shared/models/index.ts +++ b/src/app/shared/models/index.ts @@ -20,6 +20,7 @@ export * from './node-subject.model'; export * from './paginated-data.model'; export * from './query-params.model'; export * from './resource-card'; +export * from './search'; export * from './select-option.model'; export * from './severity.type'; export * from './social-icon.model'; diff --git a/src/app/shared/models/institutions/institution-json-api.model.ts b/src/app/shared/models/institutions/institution-json-api.model.ts new file mode 100644 index 00000000..e5f78877 --- /dev/null +++ b/src/app/shared/models/institutions/institution-json-api.model.ts @@ -0,0 +1,10 @@ +import { Institution, InstitutionLinks } from '@shared/models'; + +export interface InstitutionJsonApiModel { + data: { + attributes: Institution; + id: string; + links: InstitutionLinks; + }; + meta: { version: string }; +} diff --git a/src/app/shared/models/search/discaverable-filter.model.ts b/src/app/shared/models/search/discaverable-filter.model.ts new file mode 100644 index 00000000..a7ce461a --- /dev/null +++ b/src/app/shared/models/search/discaverable-filter.model.ts @@ -0,0 +1,18 @@ +import { SelectOption } from '@shared/models'; + +export interface DiscoverableFilter { + key: string; + label: string; + type: 'select' | 'date' | 'checkbox'; + operator: string; + options?: SelectOption[]; + selectedValues?: SelectOption[]; + description?: string; + helpLink?: string; + helpLinkText?: string; + resultCount?: number; + isLoading?: boolean; + isLoaded?: boolean; + hasOptions?: boolean; + loadOptionsOnExpand?: boolean; +} diff --git a/src/app/shared/models/search/filter-option.model.ts b/src/app/shared/models/search/filter-option.model.ts new file mode 100644 index 00000000..0d617189 --- /dev/null +++ b/src/app/shared/models/search/filter-option.model.ts @@ -0,0 +1,10 @@ +export interface FilterOptionMetadata { + '@id': string; + name: { '@value': string }[]; + resourceType: { '@id': string }[]; + title?: { '@value': string }[]; +} + +export interface FilterOptionAttributes { + resourceMetadata: FilterOptionMetadata; +} diff --git a/src/app/shared/models/search/filter-options-response.model.ts b/src/app/shared/models/search/filter-options-response.model.ts new file mode 100644 index 00000000..b95ff613 --- /dev/null +++ b/src/app/shared/models/search/filter-options-response.model.ts @@ -0,0 +1,28 @@ +import { ApiData } from '@osf/core/models'; + +import { FilterOptionAttributes } from './filter-option.model'; + +export interface FilterOptionsResponseData { + type: string; + id: string; + attributes: Record; + relationships?: Record; +} + +export interface FilterOptionsResponseJsonApi { + data: FilterOptionsResponseData; + included?: FilterOptionItem[]; + links?: { + first?: string; + next?: string; + prev?: string; + last?: string; + }; + meta?: { + total?: number; + page?: number; + 'per-page'?: number; + }; +} + +export type FilterOptionItem = ApiData; diff --git a/src/app/shared/models/search/index.ts b/src/app/shared/models/search/index.ts new file mode 100644 index 00000000..536356e7 --- /dev/null +++ b/src/app/shared/models/search/index.ts @@ -0,0 +1,3 @@ +export * from './discaverable-filter.model'; +export * from './filter-option.model'; +export * from './filter-options-response.model'; diff --git a/src/app/shared/services/institutions.service.ts b/src/app/shared/services/institutions.service.ts index 3331b95a..56f70fad 100644 --- a/src/app/shared/services/institutions.service.ts +++ b/src/app/shared/services/institutions.service.ts @@ -12,6 +12,7 @@ import { Institution, UserInstitutionGetResponse, } from '@shared/models'; +import { InstitutionJsonApiModel } from '@shared/models/institutions/institution-json-api.model'; import { environment } from 'src/environments/environment'; @@ -53,6 +54,12 @@ export class InstitutionsService { .pipe(map((response) => response.data.map((item) => UserInstitutionsMapper.fromResponse(item)))); } + getInstitutionById(institutionId: string): Observable { + return this.jsonApiService + .get(`${environment.apiUrl}/institutions/${institutionId}`) + .pipe(map((result) => result.data.attributes)); + } + deleteUserInstitution(id: string, userId: string): Observable { const payload = { data: [{ id: id, type: 'institutions' }], diff --git a/src/app/shared/services/search.service.ts b/src/app/shared/services/search.service.ts index 04a41882..d7a20f32 100644 --- a/src/app/shared/services/search.service.ts +++ b/src/app/shared/services/search.service.ts @@ -2,9 +2,18 @@ import { map, Observable } from 'rxjs'; import { inject, Injectable } from '@angular/core'; +import { ApiData } from '@osf/core/models'; import { JsonApiService } from '@osf/core/services'; import { MapResources } from '@osf/features/search/mappers'; -import { IndexCardSearch, ResourcesData } from '@osf/features/search/models'; +import { IndexCardSearch, ResourceItem, ResourcesData } from '@osf/features/search/models'; +import { + AppliedFilter, + CombinedFilterMapper, + FilterOptionItem, + mapFilterOption, + RelatedPropertyPathItem, +} from '@shared/mappers'; +import { FilterOptionsResponseJsonApi, SelectOption } from '@shared/models'; import { environment } from 'src/environments/environment'; @@ -12,7 +21,7 @@ import { environment } from 'src/environments/environment'; providedIn: 'root', }) export class SearchService { - #jsonApiService = inject(JsonApiService); + private readonly jsonApiService = inject(JsonApiService); getResources( filters: Record, @@ -29,13 +38,22 @@ export class SearchService { ...filters, }; - return this.#jsonApiService.get(`${environment.shareDomainUrl}/index-card-search`, params).pipe( + return this.jsonApiService.get(`${environment.shareDomainUrl}/index-card-search`, params).pipe( map((response) => { if (response?.included) { + const indexCardItems = response.included.filter( + (item): item is ApiData<{ resourceMetadata: ResourceItem }, null, null, null> => item.type === 'index-card' + ); + + const relatedPropertyPathItems = response.included.filter( + (item): item is RelatedPropertyPathItem => item.type === 'related-property-path' + ); + + const appliedFilters: AppliedFilter[] = response.data?.attributes?.cardSearchFilter || []; + return { - resources: response?.included - .filter((item) => item.type === 'index-card') - .map((item) => MapResources(item.attributes.resourceMetadata)), + resources: indexCardItems.map((item) => MapResources(item.attributes.resourceMetadata)), + filters: CombinedFilterMapper(appliedFilters, relatedPropertyPathItems), count: response.data.attributes.totalResultCount, first: response.data?.relationships?.searchResultPage?.links?.first?.href, next: response.data?.relationships?.searchResultPage?.links?.next?.href, @@ -49,13 +67,22 @@ export class SearchService { } getResourcesByLink(link: string): Observable { - return this.#jsonApiService.get(link).pipe( + return this.jsonApiService.get(link).pipe( map((response) => { if (response?.included) { + const indexCardItems = response.included.filter( + (item): item is ApiData<{ resourceMetadata: ResourceItem }, null, null, null> => item.type === 'index-card' + ); + + const relatedPropertyPathItems = response.included.filter( + (item): item is RelatedPropertyPathItem => item.type === 'related-property-path' + ); + + const appliedFilters: AppliedFilter[] = response.data?.attributes?.cardSearchFilter || []; + return { - resources: response.included - .filter((item) => item.type === 'index-card') - .map((item) => MapResources(item.attributes.resourceMetadata)), + resources: indexCardItems.map((item) => MapResources(item.attributes.resourceMetadata)), + filters: CombinedFilterMapper(appliedFilters, relatedPropertyPathItems), count: response.data.attributes.totalResultCount, first: response.data?.relationships?.searchResultPage?.links?.first?.href, next: response.data?.relationships?.searchResultPage?.links?.next?.href, @@ -67,4 +94,27 @@ export class SearchService { }) ); } + + getFilterOptions(filterKey: string): Observable { + const params: Record = { + valueSearchPropertyPath: filterKey, + 'page[size]': '50', + }; + + return this.jsonApiService + .get(`${environment.shareDomainUrl}/index-card-search`, params) + .pipe( + map((response) => { + if (response?.included) { + const filterOptionItems = response.included.filter( + (item): item is FilterOptionItem => item.type === 'index-card' && !!item.attributes?.resourceMetadata + ); + + return filterOptionItems.map((item) => mapFilterOption(item)); + } + + return []; + }) + ); + } } diff --git a/src/app/shared/stores/index.ts b/src/app/shared/stores/index.ts index 7b72ba9d..1ed4e7c7 100644 --- a/src/app/shared/stores/index.ts +++ b/src/app/shared/stores/index.ts @@ -1,3 +1,4 @@ export * from './addons'; export * from './institutions'; +export * from './institutions-search'; export * from './subjects'; diff --git a/src/app/shared/stores/institutions-search/index.ts b/src/app/shared/stores/institutions-search/index.ts new file mode 100644 index 00000000..b7801022 --- /dev/null +++ b/src/app/shared/stores/institutions-search/index.ts @@ -0,0 +1,4 @@ +export * from './institutions-search.actions'; +export * from './institutions-search.model'; +export * from './institutions-search.selectors'; +export * from './institutions-search.state'; diff --git a/src/app/shared/stores/institutions-search/institutions-search.actions.ts b/src/app/shared/stores/institutions-search/institutions-search.actions.ts new file mode 100644 index 00000000..6aeca964 --- /dev/null +++ b/src/app/shared/stores/institutions-search/institutions-search.actions.ts @@ -0,0 +1,52 @@ +import { ResourceTab } from '@shared/enums'; + +export class FetchInstitutionById { + static readonly type = '[InstitutionsSearch] Fetch Institution By Id'; + + constructor(public institutionId: string) {} +} + +export class FetchResources { + static readonly type = '[Institutions] Fetch Resources'; +} + +export class FetchResourcesByLink { + static readonly type = '[Institutions] Fetch Resources By Link'; + + constructor(public link: string) {} +} + +export class UpdateResourceType { + static readonly type = '[Institutions] Update Resource Type'; + + constructor(public type: ResourceTab) {} +} + +export class UpdateSortBy { + static readonly type = '[Institutions] Update Sort By'; + + constructor(public sortBy: string) {} +} + +export class LoadFilterOptions { + static readonly type = '[InstitutionsSearch] Load Filter Options'; + constructor(public filterKey: string) {} +} + +export class UpdateFilterValue { + static readonly type = '[InstitutionsSearch] Update Filter Value'; + constructor( + public filterKey: string, + public value: string | null + ) {} +} + +export class SetFilterValues { + static readonly type = '[InstitutionsSearch] Set Filter Values'; + constructor(public filterValues: Record) {} +} + +export class LoadFilterOptionsAndSetValues { + static readonly type = '[InstitutionsSearch] Load Filter Options And Set Values'; + constructor(public filterValues: Record) {} +} diff --git a/src/app/shared/stores/institutions-search/institutions-search.model.ts b/src/app/shared/stores/institutions-search/institutions-search.model.ts new file mode 100644 index 00000000..3307861a --- /dev/null +++ b/src/app/shared/stores/institutions-search/institutions-search.model.ts @@ -0,0 +1,18 @@ +import { ResourceTab } from '@shared/enums'; +import { AsyncStateModel, DiscoverableFilter, Institution, Resource, SelectOption } from '@shared/models'; + +export interface InstitutionsSearchModel { + institution: AsyncStateModel; + resources: AsyncStateModel; + filters: DiscoverableFilter[]; + filterValues: Record; + filterOptionsCache: Record; + providerIri: string; + resourcesCount: number; + searchText: string; + sortBy: string; + first: string; + next: string; + previous: string; + resourceType: ResourceTab; +} diff --git a/src/app/shared/stores/institutions-search/institutions-search.selectors.ts b/src/app/shared/stores/institutions-search/institutions-search.selectors.ts new file mode 100644 index 00000000..ef8d8811 --- /dev/null +++ b/src/app/shared/stores/institutions-search/institutions-search.selectors.ts @@ -0,0 +1,83 @@ +import { Selector } from '@ngxs/store'; + +import { DiscoverableFilter, Resource, SelectOption } from '@shared/models'; + +import { InstitutionsSearchModel } from './institutions-search.model'; +import { InstitutionsSearchState } from './institutions-search.state'; + +export class InstitutionsSearchSelectors { + @Selector([InstitutionsSearchState]) + static getInstitution(state: InstitutionsSearchModel) { + return state.institution.data; + } + + @Selector([InstitutionsSearchState]) + static getInstitutionLoading(state: InstitutionsSearchModel) { + return state.institution.isLoading; + } + + @Selector([InstitutionsSearchState]) + static getResources(state: InstitutionsSearchModel): Resource[] { + return state.resources.data; + } + + @Selector([InstitutionsSearchState]) + static getResourcesLoading(state: InstitutionsSearchModel): boolean { + return state.resources.isLoading; + } + + @Selector([InstitutionsSearchState]) + static getFilters(state: InstitutionsSearchModel): DiscoverableFilter[] { + return state.filters; + } + + @Selector([InstitutionsSearchState]) + static getResourcesCount(state: InstitutionsSearchModel): number { + return state.resourcesCount; + } + + @Selector([InstitutionsSearchState]) + static getSearchText(state: InstitutionsSearchModel): string { + return state.searchText; + } + + @Selector([InstitutionsSearchState]) + static getSortBy(state: InstitutionsSearchModel): string { + return state.sortBy; + } + + @Selector([InstitutionsSearchState]) + static getIris(state: InstitutionsSearchModel): string { + return state.providerIri; + } + + @Selector([InstitutionsSearchState]) + static getFirst(state: InstitutionsSearchModel): string { + return state.first; + } + + @Selector([InstitutionsSearchState]) + static getNext(state: InstitutionsSearchModel): string { + return state.next; + } + + @Selector([InstitutionsSearchState]) + static getPrevious(state: InstitutionsSearchModel): string { + return state.previous; + } + + @Selector([InstitutionsSearchState]) + static getResourceType(state: InstitutionsSearchModel) { + return state.resourceType; + } + + @Selector([InstitutionsSearchState]) + static getFilterValues(state: InstitutionsSearchModel): Record { + return state.filterValues; + } + + @Selector([InstitutionsSearchState]) + static getFilterOptionsCache(state: InstitutionsSearchModel): Record { + return state.filterOptionsCache; + } +} diff --git a/src/app/shared/stores/institutions-search/institutions-search.state.ts b/src/app/shared/stores/institutions-search/institutions-search.state.ts new file mode 100644 index 00000000..a5ae21ac --- /dev/null +++ b/src/app/shared/stores/institutions-search/institutions-search.state.ts @@ -0,0 +1,242 @@ +import { Action, NgxsOnInit, State, StateContext, Store } from '@ngxs/store'; +import { patch } from '@ngxs/store/operators'; + +import { BehaviorSubject, catchError, EMPTY, forkJoin, of, switchMap, tap, throwError } from 'rxjs'; + +import { inject, Injectable } from '@angular/core'; + +import { ResourcesData } from '@osf/features/search/models'; +import { GetResourcesRequestTypeEnum, ResourceTab } from '@shared/enums'; +import { Institution } from '@shared/models'; +import { InstitutionsService, SearchService } from '@shared/services'; +import { FetchResources, FetchResourcesByLink, InstitutionsSearchSelectors, UpdateResourceType } from '@shared/stores'; +import { getResourceTypes } from '@shared/utils'; + +import { + FetchInstitutionById, + LoadFilterOptions, + LoadFilterOptionsAndSetValues, + SetFilterValues, + UpdateFilterValue, + UpdateSortBy, +} from './institutions-search.actions'; +import { InstitutionsSearchModel } from './institutions-search.model'; + +@State({ + name: 'institutionsSearch', + defaults: { + institution: { data: {} as Institution, isLoading: false, error: null }, + resources: { data: [], isLoading: false, error: null }, + filters: [], + filterValues: {}, + filterOptionsCache: {}, + providerIri: '', + resourcesCount: 0, + searchText: '', + sortBy: '-relevance', + first: '', + next: '', + previous: '', + resourceType: ResourceTab.All, + }, +}) +@Injectable() +export class InstitutionsSearchState implements NgxsOnInit { + private readonly institutionsService = inject(InstitutionsService); + private readonly searchService = inject(SearchService); + private readonly store = inject(Store); + + private loadRequests = new BehaviorSubject<{ type: GetResourcesRequestTypeEnum; link?: string } | null>(null); + private filterOptionsRequests = new BehaviorSubject(null); + + ngxsOnInit(ctx: StateContext): void { + this.setupLoadRequests(ctx); + this.setupFilterOptionsRequests(ctx); + } + + private setupLoadRequests(ctx: StateContext) { + this.loadRequests + .pipe( + switchMap((query) => { + if (!query) return EMPTY; + return query.type === GetResourcesRequestTypeEnum.GetResources + ? this.loadResources(ctx) + : this.loadResourcesByLink(ctx, query.link); + }) + ) + .subscribe(); + } + + private loadResources(ctx: StateContext) { + const state = ctx.getState(); + ctx.patchState({ resources: { ...state.resources, isLoading: true } }); + const filtersParams: Record = {}; + const searchText = this.store.selectSnapshot(InstitutionsSearchSelectors.getSearchText); + const sortBy = this.store.selectSnapshot(InstitutionsSearchSelectors.getSortBy); + const resourceTab = this.store.selectSnapshot(InstitutionsSearchSelectors.getResourceType); + const resourceTypes = getResourceTypes(resourceTab); + + filtersParams['cardSearchFilter[affiliation][]'] = this.store.selectSnapshot(InstitutionsSearchSelectors.getIris); + + Object.entries(state.filterValues).forEach(([key, value]) => { + if (value) filtersParams[`cardSearchFilter[${key}][]`] = value; + }); + + return this.searchService + .getResources(filtersParams, searchText, sortBy, resourceTypes) + .pipe(tap((response) => this.updateResourcesState(ctx, response))); + } + + private loadResourcesByLink(ctx: StateContext, link?: string) { + if (!link) return EMPTY; + return this.searchService + .getResourcesByLink(link) + .pipe(tap((response) => this.updateResourcesState(ctx, response))); + } + + private updateResourcesState(ctx: StateContext, response: ResourcesData) { + const state = ctx.getState(); + const filtersWithCachedOptions = (response.filters || []).map((filter) => { + const cachedOptions = state.filterOptionsCache[filter.key]; + return cachedOptions?.length ? { ...filter, options: cachedOptions, isLoaded: true } : filter; + }); + + ctx.patchState({ + resources: { data: response.resources, isLoading: false, error: null }, + filters: filtersWithCachedOptions, + resourcesCount: response.count, + first: response.first, + next: response.next, + previous: response.previous, + }); + } + + private setupFilterOptionsRequests(ctx: StateContext) { + this.filterOptionsRequests + .pipe( + switchMap((filterKey) => { + if (!filterKey) return EMPTY; + return this.handleFilterOptionLoad(ctx, filterKey); + }) + ) + .subscribe(); + } + + private handleFilterOptionLoad(ctx: StateContext, filterKey: string) { + const state = ctx.getState(); + const cachedOptions = state.filterOptionsCache[filterKey]; + if (cachedOptions?.length) { + const updatedFilters = state.filters.map((f) => + f.key === filterKey ? { ...f, options: cachedOptions, isLoaded: true, isLoading: false } : f + ); + ctx.patchState({ filters: updatedFilters }); + return EMPTY; + } + + const loadingFilters = state.filters.map((f) => (f.key === filterKey ? { ...f, isLoading: true } : f)); + ctx.patchState({ filters: loadingFilters }); + + return this.searchService.getFilterOptions(filterKey).pipe( + tap((options) => { + const updatedCache = { ...ctx.getState().filterOptionsCache, [filterKey]: options }; + const updatedFilters = ctx + .getState() + .filters.map((f) => (f.key === filterKey ? { ...f, options, isLoaded: true, isLoading: false } : f)); + ctx.patchState({ filters: updatedFilters, filterOptionsCache: updatedCache }); + }) + ); + } + + @Action(FetchResources) + getResources() { + if (!this.store.selectSnapshot(InstitutionsSearchSelectors.getIris)) return; + this.loadRequests.next({ type: GetResourcesRequestTypeEnum.GetResources }); + } + + @Action(FetchResourcesByLink) + getResourcesByLink(_: StateContext, action: FetchResourcesByLink) { + this.loadRequests.next({ type: GetResourcesRequestTypeEnum.GetResourcesByLink, link: action.link }); + } + + @Action(LoadFilterOptions) + loadFilterOptions(_: StateContext, action: LoadFilterOptions) { + this.filterOptionsRequests.next(action.filterKey); + } + + @Action(FetchInstitutionById) + fetchInstitutionById(ctx: StateContext, action: FetchInstitutionById) { + ctx.patchState({ institution: { data: {} as Institution, isLoading: true, error: null } }); + + return this.institutionsService.getInstitutionById(action.institutionId).pipe( + tap((response) => { + ctx.setState( + patch({ + institution: patch({ data: response, error: null, isLoading: false }), + providerIri: response.iris.join(','), + }) + ); + this.loadRequests.next({ type: GetResourcesRequestTypeEnum.GetResources }); + }), + catchError((error) => { + ctx.patchState({ institution: { ...ctx.getState().institution, isLoading: false, error } }); + return throwError(() => error); + }) + ); + } + + @Action(LoadFilterOptionsAndSetValues) + loadFilterOptionsAndSetValues(ctx: StateContext, action: LoadFilterOptionsAndSetValues) { + const filterKeys = Object.keys(action.filterValues).filter((key) => action.filterValues[key]); + if (!filterKeys.length) return; + + const loadingFilters = ctx + .getState() + .filters.map((f) => + filterKeys.includes(f.key) && !ctx.getState().filterOptionsCache[f.key]?.length ? { ...f, isLoading: true } : f + ); + ctx.patchState({ filters: loadingFilters }); + + const observables = filterKeys.map((key) => + this.searchService.getFilterOptions(key).pipe( + tap((options) => { + const updatedCache = { ...ctx.getState().filterOptionsCache, [key]: options }; + const updatedFilters = ctx + .getState() + .filters.map((f) => (f.key === key ? { ...f, options, isLoaded: true, isLoading: false } : f)); + ctx.patchState({ filters: updatedFilters, filterOptionsCache: updatedCache }); + }), + catchError(() => of({ filterKey: key, options: [] })) + ) + ); + + return forkJoin(observables).pipe(tap(() => ctx.patchState({ filterValues: action.filterValues }))); + } + + @Action(SetFilterValues) + setFilterValues(ctx: StateContext, action: SetFilterValues) { + ctx.patchState({ filterValues: action.filterValues }); + } + + @Action(UpdateFilterValue) + updateFilterValue(ctx: StateContext, action: UpdateFilterValue) { + if (action.filterKey === 'search') { + ctx.patchState({ searchText: action.value || '' }); + this.loadRequests.next({ type: GetResourcesRequestTypeEnum.GetResources }); + return; + } + + const updatedFilterValues = { ...ctx.getState().filterValues, [action.filterKey]: action.value }; + ctx.patchState({ filterValues: updatedFilterValues }); + this.loadRequests.next({ type: GetResourcesRequestTypeEnum.GetResources }); + } + + @Action(UpdateResourceType) + updateResourceType(ctx: StateContext, action: UpdateResourceType) { + ctx.patchState({ resourceType: action.type }); + } + + @Action(UpdateSortBy) + updateSortBy(ctx: StateContext, action: UpdateSortBy) { + ctx.patchState({ sortBy: action.sortBy }); + } +} diff --git a/src/app/shared/stores/institutions/institutions.actions.ts b/src/app/shared/stores/institutions/institutions.actions.ts index 8bcc866b..f8f6c03a 100644 --- a/src/app/shared/stores/institutions/institutions.actions.ts +++ b/src/app/shared/stores/institutions/institutions.actions.ts @@ -1,9 +1,9 @@ -export class GetUserInstitutions { - static readonly type = '[Institutions] Get User Institutions'; +export class FetchUserInstitutions { + static readonly type = '[Institutions] Fetch User Institutions'; } export class FetchInstitutions { - static readonly type = '[Institutions] Get'; + static readonly type = '[Institutions] Fetch Institutions'; constructor( public pageNumber: number, diff --git a/src/app/shared/stores/institutions/institutions.state.ts b/src/app/shared/stores/institutions/institutions.state.ts index 08b8c86f..5e9691d0 100644 --- a/src/app/shared/stores/institutions/institutions.state.ts +++ b/src/app/shared/stores/institutions/institutions.state.ts @@ -6,7 +6,7 @@ import { catchError, tap, throwError } from 'rxjs'; import { inject, Injectable } from '@angular/core'; import { InstitutionsService } from '@shared/services'; -import { FetchInstitutions, GetUserInstitutions, InstitutionsStateModel } from '@shared/stores'; +import { FetchInstitutions, FetchUserInstitutions, InstitutionsStateModel } from '@shared/stores'; @State({ name: 'institutions', @@ -24,7 +24,7 @@ import { FetchInstitutions, GetUserInstitutions, InstitutionsStateModel } from ' export class InstitutionsState { private readonly institutionsService = inject(InstitutionsService); - @Action(GetUserInstitutions) + @Action(FetchUserInstitutions) getUserInstitutions(ctx: StateContext) { return this.institutionsService.getUserInstitutions().pipe( tap((institutions) => { @@ -36,7 +36,7 @@ export class InstitutionsState { } @Action(FetchInstitutions) - getInstitutions(ctx: StateContext, action: FetchInstitutions) { + fetchInstitutions(ctx: StateContext, action: FetchInstitutions) { ctx.patchState({ institutions: { data: [], diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index b28e7044..6727fdef 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -35,6 +35,17 @@ "projects": "Projects", "files": "Files", "users": "Users" + }, + "filterPlaceholders": { + "affiliation": "Select institution", + "subject": "Select subject", + "funder": "Select funder", + "rights": "Select license", + "publisher": "Select provider", + "isPartOfCollection": "Select part of collection", + "dateCreated": "Select date", + "creator": "Creator name", + "resourceType": "Select resource type" } }, "labels": { @@ -895,6 +906,9 @@ "placeholder": "Enter search term(s) here" }, "filters": { + "sortBy": "Sort by", + "noFiltersAvailable": "No filters available", + "noOptionsAvailable": "No options available", "programArea": { "label": "Program Area", "placeholder": "Select program areas", diff --git a/src/assets/styles/_common.scss b/src/assets/styles/_common.scss index 70e6783a..11a2f04e 100644 --- a/src/assets/styles/_common.scss +++ b/src/assets/styles/_common.scss @@ -103,3 +103,8 @@ .cursor-not-allowed { cursor: not-allowed; } + +// ------------------------- Object fit contain ------------------------- +.fit-contain { + object-fit: contain; +}