From 045c55c9b126a602472084bfc1150967eb3caf32 Mon Sep 17 00:00:00 2001 From: Rongrong Chai <73901500+rrchai@users.noreply.github.com> Date: Thu, 6 Jun 2024 13:18:25 -0400 Subject: [PATCH] fix(openchallenges): fix bugs related search dropdown filters on search page (#2688) Co-authored-by: Thomas Schaffter --- .../src/lib/challenge-search-dropdown.ts | 6 + .../src/lib/challenge-search.component.html | 23 +-- .../src/lib/challenge-search.component.ts | 149 +++++++++++------- .../search-dropdown-filter.component.html | 49 ++---- .../search-dropdown-filter.component.scss | 4 +- .../search-dropdown-filter.component.ts | 69 ++++++-- 6 files changed, 183 insertions(+), 117 deletions(-) diff --git a/libs/openchallenges/challenge-search/src/lib/challenge-search-dropdown.ts b/libs/openchallenges/challenge-search/src/lib/challenge-search-dropdown.ts index d745967cbb..134483b481 100644 --- a/libs/openchallenges/challenge-search/src/lib/challenge-search-dropdown.ts +++ b/libs/openchallenges/challenge-search/src/lib/challenge-search-dropdown.ts @@ -2,3 +2,9 @@ export type ChallengeSearchDropdown = | 'inputDataTypes' | 'organizations' | 'platforms'; + +export const CHALLENGE_SEARCH_DROPDOWNS: ChallengeSearchDropdown[] = [ + 'inputDataTypes', + 'organizations', + 'platforms', +]; diff --git a/libs/openchallenges/challenge-search/src/lib/challenge-search.component.html b/libs/openchallenges/challenge-search/src/lib/challenge-search.component.html index 9172703d87..baacce64bf 100644 --- a/libs/openchallenges/challenge-search/src/lib/challenge-search.component.html +++ b/libs/openchallenges/challenge-search/src/lib/challenge-search.component.html @@ -41,7 +41,7 @@

Challenges

> @@ -54,7 +54,7 @@

Challenges

> @@ -67,7 +67,7 @@

Challenges

> @@ -80,13 +80,14 @@

Challenges

> @@ -97,13 +98,14 @@

Challenges

> @@ -114,13 +116,14 @@

Challenges

> @@ -162,7 +165,7 @@

Challenges

> diff --git a/libs/openchallenges/challenge-search/src/lib/challenge-search.component.ts b/libs/openchallenges/challenge-search/src/lib/challenge-search.component.ts index 78de892c73..49964176eb 100644 --- a/libs/openchallenges/challenge-search/src/lib/challenge-search.component.ts +++ b/libs/openchallenges/challenge-search/src/lib/challenge-search.component.ts @@ -65,8 +65,10 @@ import { SeoService } from '@sagebionetworks/shared/util'; import { getSeoData } from './challenge-search-seo-data'; import { HttpParams } from '@angular/common/http'; import { ChallengeSearchDataService } from './challenge-search-data.service'; -import { MultiSelectLazyLoadEvent } from 'primeng/multiselect'; -import { ChallengeSearchDropdown } from './challenge-search-dropdown'; +import { + ChallengeSearchDropdown, + CHALLENGE_SEARCH_DROPDOWNS, +} from './challenge-search-dropdown'; @Component({ selector: 'openchallenges-challenge-search', @@ -133,6 +135,7 @@ export class ChallengeSearchComponent // set default values defaultPageNumber = 0; defaultPageSize = 24; + defaultDropdownOptionSize = 50; defaultSelectedYear = undefined; defaultSortedBy: ChallengeSort = 'relevance'; @@ -148,16 +151,17 @@ export class ChallengeSearchComponent // set dropdown filter placeholders dropdownFilters!: { [key: string]: FilterPanel }; - loadedPages!: { [key: string]: Set }; // define selected filter values - selectedCategories!: ChallengeCategory[]; - selectedIncentives!: ChallengeIncentive[]; - selectedInputDataTypes!: number[]; - selectedOrgs!: number[]; - selectedPlatforms!: string[]; - selectedStatus!: ChallengeStatus[]; - selectedSubmissionTypes!: ChallengeSubmissionType[]; + selectedValues = { + categories: [] as ChallengeCategory[], + incentives: [] as ChallengeIncentive[], + inputDataTypes: [] as number[], + organizations: [] as number[], + platforms: [] as string[], + status: [] as ChallengeStatus[], + submissionTypes: [] as ChallengeSubmissionType[], + }; constructor( private activatedRoute: ActivatedRoute, @@ -200,36 +204,40 @@ export class ChallengeSearchComponent } // update selected filter values based on params in url - this.selectedCategories = this.splitParam(params['categories']); - this.selectedIncentives = this.splitParam(params['incentives']); - this.selectedInputDataTypes = this.splitParam( - params['inputDataTypes'] - ).map((idString) => +idString); + this.searchedTerms = params['searchTerms']; - this.selectedOrgs = this.splitParam(params['organizations']).map( - (idString) => +idString - ); this.selectedPageNumber = +params['pageNumber'] || this.defaultPageNumber; this.selectedPageSize = this.defaultPageSize; // no available pageSize options for users - this.selectedPlatforms = this.splitParam(params['platforms']); - this.selectedStatus = this.splitParam(params['status']); - this.selectedSubmissionTypes = this.splitParam(params['submissionTypes']); this.sortedBy = params['sort'] || this.defaultSortedBy; + this.selectedValues['categories'] = this.splitParam(params['categories']); + this.selectedValues['incentives'] = this.splitParam(params['incentives']); + this.selectedValues['inputDataTypes'] = this.splitParam( + params['inputDataTypes'] + ).map((idString) => +idString); + this.selectedValues['organizations'] = this.splitParam( + params['organizations'] + ).map((idString) => +idString); + this.selectedValues['platforms'] = this.splitParam(params['platforms']); + this.selectedValues['status'] = this.splitParam(params['status']); + this.selectedValues['submissionTypes'] = this.splitParam( + params['submissionTypes'] + ); + const defaultQuery: ChallengeSearchQuery = { - categories: this.selectedCategories, - incentives: this.selectedIncentives, - inputDataTypes: this.selectedInputDataTypes, + categories: this.selectedValues['categories'], + incentives: this.selectedValues['incentives'], + inputDataTypes: this.selectedValues['inputDataTypes'], maxStartDate: this.selectedMaxStartDate, minStartDate: this.selectedMinStartDate, - organizations: this.selectedOrgs, + organizations: this.selectedValues['organizations'], pageNumber: this.selectedPageNumber, pageSize: this.selectedPageSize, - platforms: this.selectedPlatforms, + platforms: this.selectedValues['platforms'], searchTerms: this.searchedTerms, sort: this.sortedBy, - status: this.selectedStatus, - submissionTypes: this.selectedSubmissionTypes, + status: this.selectedValues['status'], + submissionTypes: this.selectedValues['submissionTypes'], }; this.query.next(defaultQuery); @@ -240,12 +248,7 @@ export class ChallengeSearchComponent this.totalChallengesCount = page.totalElements; }); - // update loaded pages and dropdown filters - this.loadedPages = { - inputDataTypes: new Set(), - organizations: new Set(), - platforms: new Set(), - }; + // update dropdown filters this.dropdownFilters = { inputDataTypes: challengeInputDataTypesFilterPanel, organizations: challengeOrganizationsFilterPanel, @@ -330,6 +333,14 @@ export class ChallengeSearchComponent // this.selectedPageSize = this.defaultPageSize; this.paginator.resetPageNumber(); } + + // update selected filter values if specific parameters change + Object.keys(filteredQuery).forEach((key) => { + if (key in this.selectedValues && filteredQuery[key] !== undefined) { + (this.selectedValues as any)[key] = filteredQuery[key]; + } + }); + // update params of URL const currentParams = new HttpParams({ fromString: this._location.path().split('?')[1] ?? '', @@ -365,52 +376,68 @@ export class ChallengeSearchComponent if (searchType === 'challenges') { this.challengeSearchTerms.next(searched); } else { - this.loadedPages[searchType].clear(); - this.dropdownFilters[searchType].options = []; - this.challengeSearchDataService.setSearchQuery(searchType, { + // reset options except selections when search term is applied + const selectedOptions = this.dropdownFilters[searchType].options.filter( + (option) => { + if (Array.isArray(option.value)) { + return option.value.some((item) => + (this.selectedValues[searchType] as FilterValue[]).includes(item) + ); + } else { + return (this.selectedValues[searchType] as FilterValue[]).includes( + option.value + ); + } + } + ); + this.dropdownFilters[searchType].options = selectedOptions; + this.challengeSearchDataService.setEdamConceptSearchQuery({ searchTerms: searched, }); } } - onLazyLoad( - dropdown: ChallengeSearchDropdown, - event: MultiSelectLazyLoadEvent - ): void { - const size = this.defaultPageSize; - const startPage = Math.floor(event.first / size); - const endPage = Math.floor(event.last / size); + onLazyLoad(dropdown: ChallengeSearchDropdown, page: number): void { + const query: any = { pageNumber: page }; - // load next page as scrolling down - for (let page = startPage; page <= endPage; page++) { - if (!this.loadedPages[dropdown].has(page)) { - this.loadedPages[dropdown].add(page); - this.challengeSearchDataService.setSearchQuery(dropdown, { - pageNumber: page, - pageSize: size, - }); - } + if (page === 0) { + // reset ids and slugs params of dropdown search query + query.ids = undefined; + query.slugs = undefined; } + // load next page as scrolling down + this.challengeSearchDataService.setSearchQuery(dropdown, query); + } + + private setDropdownSelections(): void { + this.challengeSearchDataService.setSearchQuery('inputDataTypes', { + ids: this.selectedValues['inputDataTypes'], + }); + this.challengeSearchDataService.setSearchQuery('organizations', { + ids: this.selectedValues['organizations'], + }); + this.challengeSearchDataService.setSearchQuery('platforms', { + slugs: this.selectedValues['platforms'], + }); } private loadInitialDropdownData(): void { - const dropdowns = [ - 'inputDataTypes', - 'organizations', - 'platforms', - ] as ChallengeSearchDropdown[]; - dropdowns.forEach((dropdown) => { + // query the dropdown filter option(s) pre-selected in url param (only initially) + this.setDropdownSelections(); + + // fetch and update dropdown options with new data for each dropdown category + CHALLENGE_SEARCH_DROPDOWNS.forEach((dropdown) => { const extraDefaultParams = dropdown === 'inputDataTypes' ? { sections: [EdamSection.Data] } : {}; this.challengeSearchDataService .fetchData(dropdown, { - pageSize: this.defaultPageSize, // set constant pageSize to match lazyLoad + pageSize: this.defaultDropdownOptionSize, // set constant pageSize to match lazyLoad ...extraDefaultParams, }) .pipe(takeUntil(this.destroy)) .subscribe((newOptions) => { - // update filter options by taking unique filter values + // update filter options by appending new unique filter values to the bottom this.dropdownFilters[dropdown].options = unionWith( this.dropdownFilters[dropdown].options, newOptions, diff --git a/libs/openchallenges/ui/src/lib/search-dropdown-filter/search-dropdown-filter.component.html b/libs/openchallenges/ui/src/lib/search-dropdown-filter/search-dropdown-filter.component.html index db711863c2..fda7fea73a 100644 --- a/libs/openchallenges/ui/src/lib/search-dropdown-filter/search-dropdown-filter.component.html +++ b/libs/openchallenges/ui/src/lib/search-dropdown-filter/search-dropdown-filter.component.html @@ -3,14 +3,12 @@ [(ngModel)]="selectedOptions" (onChange)="onChange(selectedOptions)" [placeholder]="placeholder" - [maxSelectedLabels]="2" - selectedItemsLabel="{0} items selected" [overlayOptions]="overlayOptions" [filter]="filter" (onFilter)="onSearch($event)" [virtualScroll]="true" - [virtualScrollItemSize]="50" - [virtualScrollOptions]="scrollOptions" + [virtualScrollItemSize]="optionHeight" + [virtualScrollOptions]="scrollerOptions" [lazy]="lazy" (onLazyLoad)="onLazyLoad($event)" > @@ -29,47 +27,32 @@ - + -
-
+
+
- +
- + -
-
-
- - -
-
- -
- -
-
-
- -
- No results found -
-
+ + No results found + +
+ + + +
{{ validSelectionCount }} item(s) selected
+
{{ placeholder }}
diff --git a/libs/openchallenges/ui/src/lib/search-dropdown-filter/search-dropdown-filter.component.scss b/libs/openchallenges/ui/src/lib/search-dropdown-filter/search-dropdown-filter.component.scss index 8b9cd88dce..341112fdc8 100644 --- a/libs/openchallenges/ui/src/lib/search-dropdown-filter/search-dropdown-filter.component.scss +++ b/libs/openchallenges/ui/src/lib/search-dropdown-filter/search-dropdown-filter.component.scss @@ -72,13 +72,13 @@ } } -.loader-with-icon-container { +.loader-with-avatar-container { display: flex; flex-direction: column; align-items: center; justify-content: center; - .skeleton-with-icon { + .skeleton-with-avatar { display: flex; align-items: center; margin-top: 6px; diff --git a/libs/openchallenges/ui/src/lib/search-dropdown-filter/search-dropdown-filter.component.ts b/libs/openchallenges/ui/src/lib/search-dropdown-filter/search-dropdown-filter.component.ts index 48831eeb82..40eb043aec 100644 --- a/libs/openchallenges/ui/src/lib/search-dropdown-filter/search-dropdown-filter.component.ts +++ b/libs/openchallenges/ui/src/lib/search-dropdown-filter/search-dropdown-filter.component.ts @@ -9,6 +9,7 @@ import { MultiSelectModule, } from 'primeng/multiselect'; import { SkeletonModule } from 'primeng/skeleton'; +import { ScrollerOptions } from 'primeng/api'; @Component({ selector: 'openchallenges-search-dropdown-filter', @@ -25,32 +26,41 @@ import { SkeletonModule } from 'primeng/skeleton'; }) export class SearchDropdownFilterComponent implements OnInit { @Input({ required: true }) options!: Filter[]; + @Input({ required: true }) optionsPerPage!: number; @Input({ required: true }) selectedOptions!: any[]; @Input({ required: true }) placeholder = 'Search items'; @Input({ required: true }) showAvatar!: boolean | undefined; @Input({ required: true }) filterByApiClient!: boolean | undefined; @Input({ required: false }) lazy = true; + @Input({ required: false }) showLoader = false; + @Input({ required: false }) optionHeight = 50; // height of each option + @Input({ required: false }) optionSize = 10; // total number of displaying options @Output() selectionChange = new EventEmitter(); @Output() searchChange = new EventEmitter(); - @Output() lazyLoad = new EventEmitter(); + @Output() pageChange = new EventEmitter(); overlayOptions = { showTransitionOptions: '0ms', hideTransitionOptions: '0ms', }; - scrollOptions = { - delay: 250, - showLoader: true, - }; - searchTerm = ''; filter = true; isLoading = false; + loadedPages = new Set(); + delays = 400; + scrollerOptions!: ScrollerOptions; ngOnInit(): void { + this.scrollerOptions = { + delay: this.showLoader ? this.delays : 0, // if no loader is applied, load data seamlessly + showLoader: this.showLoader, + step: this.optionsPerPage, + scrollHeight: `${this.optionHeight * this.optionSize + 12}px`, // dynamically set scroller height + }; + this.showAvatar = this.showAvatar ? this.showAvatar : false; if (this.filterByApiClient) { @@ -60,14 +70,40 @@ export class SearchDropdownFilterComponent implements OnInit { } } + get validSelectionCount(): number { + if (!this.options) { + return 0; + } + + // preparing a set for quick lookup + const validOptionValues = new Set( + this.options.map((option) => option.value) + ); + + // count how many selected values + // exlude the invalid selected values if they are not in the option list + return this.selectedOptions.filter( + (option) => + option !== null && option !== undefined && validOptionValues.has(option) + ).length; + } + onLazyLoad(event: MultiSelectLazyLoadEvent) { // note: virtual scroll needs to be set 'true' to enable lazy load // trigger loader animation every time lazy load initiated - this.isLoading = true; - setTimeout(() => { - this.isLoading = false; - }, 250); - this.lazyLoad.emit(event); + const startPage = Math.max( + 0, + Math.floor(event.first / this.optionsPerPage) + ); // avoid negative pages + const endPage = Math.floor(event.last / this.optionsPerPage); + + // load next page as scrolling down + for (let page = startPage; page <= endPage; page++) { + if (!this.loadedPages.has(page)) { + this.loadedPages.add(page); + this.pageChange.emit(page); + } + } } onSearch(event: any): void { @@ -75,6 +111,10 @@ export class SearchDropdownFilterComponent implements OnInit { } onCustomSearch(): void { + if (this.lazy) { + this.loadedPages.clear(); + this.triggerLoading(); + } this.searchChange.emit(this.searchTerm); } @@ -83,6 +123,13 @@ export class SearchDropdownFilterComponent implements OnInit { this.selectionChange.emit(selected); } + triggerLoading(): void { + this.isLoading = true; + setTimeout(() => { + this.isLoading = false; + }, this.delays); + } + getAvatar(option: Filter): Avatar { return { name: option.label ?? '',