/* * SonarQube * Copyright (C) 2009-2023 SonarSource SA * mailto:info AT sonarsource DOT com * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3 of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public License * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import classNames from 'classnames'; import { sortBy, without } from 'lodash'; import * as React from 'react'; import ListFooter from '../../components/controls/ListFooter'; import SearchBox from '../../components/controls/SearchBox'; import { Alert } from '../../components/ui/Alert'; import { translate } from '../../helpers/l10n'; import { formatMeasure } from '../../helpers/measures'; import { queriesEqual } from '../../helpers/query'; import { Dict, Paging, RawQuery } from '../../types/types'; import FacetBox from './FacetBox'; import FacetHeader from './FacetHeader'; import FacetItem from './FacetItem'; import FacetItemsList from './FacetItemsList'; import ListStyleFacetFooter from './ListStyleFacetFooter'; import MultipleSelectionHint from './MultipleSelectionHint'; interface SearchResponse { maxResults?: boolean; results: S[]; paging?: Paging; } export interface Props { className?: string; disabled?: boolean; disabledHelper?: string; facetHeader: string; fetching: boolean; getFacetItemText: (item: string) => string; getSearchResultKey: (result: S) => string; getSearchResultText: (result: S) => string; loadSearchResultCount?: (result: S[]) => Promise>; maxInitialItems: number; maxItems: number; minSearchLength: number; onChange: (changes: Dict) => void; onClear?: () => void; onItemClick?: (itemValue: string, multiple: boolean) => void; onSearch: (query: string, page?: number) => Promise>; onToggle: (property: string) => void; open: boolean; property: string; query?: RawQuery; renderFacetItem: (item: string) => React.ReactNode; renderSearchResult: (result: S, query: string) => React.ReactNode; searchPlaceholder: string; getSortedItems?: () => string[]; stats: Dict | undefined; values: string[]; showMoreAriaLabel?: string; showLessAriaLabel?: string; } interface State { autoFocus: boolean; query: string; searching: boolean; searchMaxResults?: boolean; searchPaging?: Paging; searchResults?: S[]; searchResultsCounts: Dict; showFullList: boolean; } export default class ListStyleFacet extends React.Component, State> { mounted = false; static defaultProps = { maxInitialItems: 15, maxItems: 100, minSearchLength: 2, }; state: State = { autoFocus: false, query: '', searching: false, searchResultsCounts: {}, showFullList: false, }; componentDidMount() { this.mounted = true; } componentDidUpdate(prevProps: Props) { if (!prevProps.open && this.props.open) { // focus search field *only* if it was manually open this.setState({ autoFocus: true }); } else if ( (prevProps.open && !this.props.open) || !queriesEqual(prevProps.query || {}, this.props.query || {}) ) { // reset state when closing the facet, or when query changes this.setState({ query: '', searchMaxResults: undefined, searchResults: undefined, searching: false, searchResultsCounts: {}, showFullList: false, }); } else if ( prevProps.stats !== this.props.stats && Object.keys(this.props.stats || {}).length < this.props.maxInitialItems ) { // show limited list if `stats` changed and there are less than 15 items this.setState({ showFullList: false }); } } componentWillUnmount() { this.mounted = false; } handleItemClick = (itemValue: string, multiple: boolean) => { if (this.props.onItemClick) { this.props.onItemClick(itemValue, multiple); } else { const { values } = this.props; if (multiple) { const newValue = sortBy( values.includes(itemValue) ? without(values, itemValue) : [...values, itemValue] ); this.props.onChange({ [this.props.property]: newValue }); } else { this.props.onChange({ [this.props.property]: values.includes(itemValue) && values.length < 2 ? [] : [itemValue], }); } } }; handleHeaderClick = () => { this.props.onToggle(this.props.property); }; handleClear = () => { if (this.props.onClear) { this.props.onClear(); } else { this.props.onChange({ [this.props.property]: [] }); } }; stopSearching = () => { if (this.mounted) { this.setState({ searching: false }); } }; search = (query: string) => { if (query.length >= this.props.minSearchLength) { this.setState({ query, searching: true }); this.props .onSearch(query) .then(this.loadCountsForSearchResults) .then(({ maxResults, paging, results, stats }) => { if (this.mounted) { this.setState((state) => ({ searching: false, searchMaxResults: maxResults, searchResults: results, searchPaging: paging, searchResultsCounts: { ...state.searchResultsCounts, ...stats }, })); } }) .catch(this.stopSearching); } else { this.setState({ query, searching: false, searchResults: [] }); } }; searchMore = () => { const { query, searchPaging, searchResults } = this.state; if (query && searchResults && searchPaging) { this.setState({ searching: true }); this.props .onSearch(query, searchPaging.pageIndex + 1) .then(this.loadCountsForSearchResults) .then(({ paging, results, stats }) => { if (this.mounted) { this.setState((state) => ({ searching: false, searchResults: [...searchResults, ...results], searchPaging: paging, searchResultsCounts: { ...state.searchResultsCounts, ...stats }, })); } }) .catch(this.stopSearching); } }; loadCountsForSearchResults = (response: SearchResponse) => { const { loadSearchResultCount = () => Promise.resolve({}) } = this.props; const resultsToLoad = response.results.filter((result) => { const key = this.props.getSearchResultKey(result); return this.getStat(key) === undefined && this.state.searchResultsCounts[key] === undefined; }); if (resultsToLoad.length > 0) { return loadSearchResultCount(resultsToLoad).then((stats) => ({ ...response, stats })); } else { return { ...response, stats: {} }; } }; getStat(item: string) { const { stats } = this.props; return stats && stats[item] !== undefined ? stats && stats[item] : undefined; } getFacetHeaderId = (property: string) => { return `facet_${property}`; }; showFullList = () => { this.setState({ showFullList: true }); }; hideFullList = () => { this.setState({ showFullList: false }); }; renderList() { const { maxInitialItems, maxItems, property, stats, showMoreAriaLabel, showLessAriaLabel, values, } = this.props; if (!stats) { return null; } const sortedItems = this.props.getSortedItems ? this.props.getSortedItems() : sortBy( Object.keys(stats), (key) => -stats[key], (key) => this.props.getFacetItemText(key) ); const limitedList = this.state.showFullList ? sortedItems : sortedItems.slice(0, maxInitialItems); // make sure all selected items are displayed const selectedBelowLimit = this.state.showFullList ? [] : sortedItems.slice(maxInitialItems).filter((item) => values.includes(item)); const mightHaveMoreResults = sortedItems.length >= maxItems; return ( <> {limitedList.map((item) => ( ))} {selectedBelowLimit.length > 0 && ( <>
{selectedBelowLimit.map((item) => ( ))} )} {mightHaveMoreResults && this.state.showFullList && ( {translate('facet_might_have_more_results')} )} ); } renderSearch() { return ( ); } renderSearchResults() { const { property, showMoreAriaLabel } = this.props; const { searching, searchMaxResults, searchResults, searchPaging } = this.state; if (!searching && (!searchResults || !searchResults.length)) { return
{translate('no_results')}
; } if (!searchResults) { // initial search return null; } return ( <> {searchResults.map((result) => this.renderSearchResult(result))} {searchMaxResults && ( {translate('facet_might_have_more_results')} )} {searchPaging && ( )} ); } renderSearchResult(result: S) { const key = this.props.getSearchResultKey(result); const active = this.props.values.includes(key); const stat = this.getStat(key) || this.state.searchResultsCounts[key]; return ( ); } render() { const { className, disabled, disabledHelper, facetHeader, fetching, open, property, stats = {}, values: propsValues, } = this.props; const { query, searching, searchResults } = this.state; const values = propsValues.map((item) => this.props.getFacetItemText(item)); const loadingResults = query !== '' && searching && (searchResults === undefined || searchResults.length === 0); const showList = !query || loadingResults; return ( {open && !disabled && ( <> {this.renderSearch()} {showList ? this.renderList() : this.renderSearchResults()} )} ); } } function formatFacetStat(stat: number | undefined) { return stat && formatMeasure(stat, 'SHORT_INT'); }