import classnames from 'classnames' ; import every from 'lodash/every' ; import isEmpty from 'lodash/isEmpty' ; import isNull from 'lodash/isNull' ; import isUndefined from 'lodash/isUndefined' ; import { Dispatch , Fragment , SetStateAction , useContext , useEffect , useState } from 'react' ; import { FaFilter } from 'react-icons/fa' ; import { IoMdCloseCircleOutline } from 'react-icons/io' ; import { useHistory } from 'react-router-dom' ; import API from '../../api' ; import { AppCtx , updateLimit } from '../../context/AppCtx' ; import useScrollRestorationFix from '../../hooks/useScrollRestorationFix' ; import { FacetOption , Facets , Package , RepositoryKind , SearchFiltersURL , SearchResults , TsQuery } from '../../types' ; import { TS_QUERY } from '../../utils/data' ; import getSampleQueries from '../../utils/getSampleQueries' ; import { prepareQueryString } from '../../utils/prepareQueryString' ; import Loading from '../common/Loading' ; import NoData from '../common/NoData' ; import PackageCard from '../common/PackageCard' ; import Pagination from '../common/Pagination' ; import SampleQueries from '../common/SampleQueries' ; import Sidebar from '../common/Sidebar' ; import Footer from '../navigation/Footer' ; import SubNavbar from '../navigation/SubNavbar' ; import FilterBadge from './FilterBadge' ; import Filters from './Filters' ; import MoreActionsButton from './MoreActionsButton' ; import PaginationLimit from './PaginationLimit' ; import styles from './SearchView.module.css' ; import SortOptions from './SortOptions' ; interface FiltersProp { [ key ] : string [ ] ; } interface Props { isSearching : boolean ; setIsSearching : Dispatch < SetStateAction < boolean > > ; scrollPosition ? : number ; setScrollPosition : Dispatch < SetStateAction < number | undefined > > ; tsQueryWeb ? : string ; tsQuery ? : string [ ] ; pageNumber : number ; filters ? : FiltersProp ; deprecated ? : boolean | null ; operators ? : boolean | null ; verifiedPublisher ? : boolean | null ; official ? : boolean | null ; fromDetail : boolean ; visibleModal ? : string ; sort ? : string | null ; } const DEFAULT_SORT = 'relevance' ; interface FilterLabel { key : string ; name : string ; } const SearchView = ( props ) => { const { ctx , dispatch } = useContext ( AppCtx ) ; const history = useHistory ( ) ; const sampleQueries = getSampleQueries ( ) ; const [ searchResults , setSearchResults ] = useState < SearchResults > ( { facets : null , packages : null , paginationTotalCount : '0' , } ) ; const { isSearching , setIsSearching , scrollPosition , setScrollPosition } = props ; const [ apiError , setApiError ] = useState < string | null > ( null ) ; const [ currentTsQueryWeb , setCurrentTsQueryWeb ] = useState ( props . tsQueryWeb ) ; const calculateOffset = ( ) : number => { return props . pageNumber && ctx . prefs . search . limit ? ( props . pageNumber - 1 ) * ctx . prefs . search . limit : 0 ; } ; const [ offset , setOffset ] = useState < number > ( calculateOffset ( ) ) ; const getFilterName = ( key , label ) : FilterLabel | null => { const correctKey = [ 'user' , 'org' ] . includes ( key ) ? 'publisher' : key ; if ( searchResults . facets ) { const selectedKey = searchResults . facets . find ( ( fac ) => fac . filterKey === correctKey ) ; if ( selectedKey ) { const selectedOpt = selectedKey . options . find ( ( opt ) => opt . id . toString ( ) === label ) ; if ( selectedOpt ) { return { key : selectedKey . title , name : selectedOpt . name } ; } else { return null ; } } else { return null ; } } return null ; } ; const getTsQueryName = ( ts ) : string | null => { const selectedTsQuery = TS_QUERY . find ( ( query ) => query . label === ts ) ; if ( selectedTsQuery ) { return selectedTsQuery . name ; } else { return null ; } } ; const isEmptyFacets = ( ) : boolean => { if ( searchResults . facets ) { return every ( searchResults . facets , ( f ) => { return f . options . length === 0 ; } ) ; } else { return true ; } } ; useScrollRestorationFix ( ) ; const saveScrollPosition = ( ) => { setScrollPosition ( window . scrollY ) ; } ; const updateWindowScrollPosition = ( newPosition ) => { window . scrollTo ( 0 , newPosition ) ; } ; const prepareSelectedFilters = ( name , newFilters , prevFilters ) : FiltersProp => { let cleanFilters = { } ; switch ( name ) { case 'kind' : if ( newFilters . length > 0 && ! newFilters . includes ( RepositoryKind . Helm . toString ( ) ) ) { cleanFilters [ 'repo' ] = [ ] ; } break ; } return { ... prevFilters , [ name ] : newFilters , ... cleanFilters , } ; } ; const getCurrentFilters = ( ) : SearchFiltersURL => { return { pageNumber : props . pageNumber , tsQueryWeb : props . tsQueryWeb , tsQuery : props . tsQuery , filters : props . filters , deprecated : props . deprecated , operators : props . operators , verifiedPublisher : props . verifiedPublisher , official : props . official , sort : props . sort , } ; } ; const updateCurrentPage = ( searchChanges ) => { history . push ( { pathname : '/packages/search' , search : prepareQueryString ( { ... getCurrentFilters ( ) , pageNumber : 1 , ... searchChanges , } ) , } ) ; } ; const onFiltersChange = ( name , value , checked ) : void => { const currentFilters = props . filters || { } ; let newFilters = isUndefined ( currentFilters [ name ] ) ? [ ] : currentFilters [ name ] . slice ( ) ; if ( checked ) { newFilters . push ( value ) ; } else { newFilters = newFilters . filter ( ( el ) => el !== value ) ; } updateCurrentPage ( { filters : prepareSelectedFilters ( name , newFilters , currentFilters ) , } ) ; } ; const onResetSomeFilters = ( filterKeys ) : void => { let newFilters = { } ; filterKeys . forEach ( ( fKey ) => { newFilters [ fKey ] = [ ] ; } ) ; updateCurrentPage ( { filters : { ... props . filters , ... newFilters } , } ) ; } ; const onTsQueryChange = ( value , checked ) : void => { let query = isUndefined ( props . tsQuery ) ? [ ] : props . tsQuery . slice ( ) ; if ( checked ) { query . push ( value ) ; } else { query = query . filter ( ( el ) => el !== value ) ; } updateCurrentPage ( { tsQuery : query , } ) ; } ; const onDeprecatedChange = ( ) : void => { updateCurrentPage ( { deprecated : ! isUndefined ( props . deprecated ) && ! isNull ( props . deprecated ) ? ! props . deprecated : true , } ) ; } ; const onOperatorsChange = ( ) : void => { updateCurrentPage ( { operators : ! isUndefined ( props . operators ) && ! isNull ( props . operators ) ? ! props . operators : true , } ) ; } ; const onVerifiedPublisherChange = ( ) : void => { updateCurrentPage ( { verifiedPublisher : ! isUndefined ( props . verifiedPublisher ) && ! isNull ( props . verifiedPublisher ) ? ! props . verifiedPublisher : true , } ) ; } ; const onOfficialChange = ( ) : void => { updateCurrentPage ( { official : ! isUndefined ( props . official ) && ! isNull ( props . official ) ? ! props . official : true , } ) ; } ; const onResetFilters = ( ) : void => { history . push ( { pathname : '/packages/search' , search : prepareQueryString ( { pageNumber : 1 , tsQueryWeb : props . tsQueryWeb , tsQuery : [ ] , filters : { } , sort : DEFAULT_SORT , } ) , } ) ; } ; const onPageNumberChange = ( pageNumber ) : void => { updateCurrentPage ( { pageNumber : pageNumber , } ) ; } ; const onSortChange = ( sort ) : void => { history . replace ( { pathname : '/packages/search' , search : prepareQueryString ( { ... getCurrentFilters ( ) , sort : sort , pageNumber : 1 , } ) , } ) ; setScrollPosition ( 0 ) ; updateWindowScrollPosition ( 0 ) ; } ; const onPaginationLimitChange = ( newLimit ) : void => { history . replace ( { pathname : '/packages/search' , search : prepareQueryString ( { ... getCurrentFilters ( ) , pageNumber : 1 , } ) , } ) ; setScrollPosition ( 0 ) ; updateWindowScrollPosition ( 0 ) ; dispatch ( updateLimit ( newLimit ) ) ; } ; useEffect ( ( ) => { async function fetchSearchResults ( ) { setIsSearching ( true ) ; const query = { tsQueryWeb : props . tsQueryWeb , tsQuery : props . tsQuery , filters : props . filters , offset : calculateOffset ( ) , limit : ctx . prefs . search . limit , deprecated : props . deprecated , operators : props . operators , verifiedPublisher : props . verifiedPublisher , official : props . official , sort : props . sort || DEFAULT_SORT , } ; try { let newSearchResults = await API . searchPackages ( query ) ; if ( newSearchResults . paginationTotalCount === '0' && searchResults . facets && ! isEmpty ( searchResults . facets ) && currentTsQueryWeb === props . tsQueryWeb ) { newSearchResults = { ... newSearchResults , facets : searchResults . facets , } ; } setSearchResults ( newSearchResults ) ; setOffset ( query . offset ) ; setCurrentTsQueryWeb ( props . tsQueryWeb ) ; setApiError ( null ) ; } catch { setSearchResults ( { facets : [ ] , packages : [ ] , paginationTotalCount : '0' , } ) ; setApiError ( 'An error occurred searching packages, please try again later.' ) ; } finally { setIsSearching ( false ) ; if ( history . action === 'PUSH' ) { if ( props . fromDetail && ! isUndefined ( scrollPosition ) ) { updateWindowScrollPosition ( scrollPosition ) ; } else { updateWindowScrollPosition ( 0 ) ; } } else if ( ! isUndefined ( scrollPosition ) ) { updateWindowScrollPosition ( scrollPosition ) ; } } } fetchSearchResults ( ) ; } , [ props . tsQueryWeb , JSON . stringify ( props . tsQuery ) , props . pageNumber , JSON . stringify ( props . filters ) , props . deprecated , props . operators , props . verifiedPublisher , props . official , ctx . prefs . search . limit , props . sort , ] ) ; const activeFilters = props . deprecated || props . operators || props . verifiedPublisher || props . official || ! isUndefined ( props . tsQuery ) || ! isEmpty ( props . filters ) ; return ( < > < SubNavbar className = { `h-auto ${ styles . subnavbar }` } > < div className = "d-flex flex-column w-100" > < div className = "d-flex align-items-center justify-content-between flex-nowrap" > < div className = "d-flex align-items-center text-truncate w-100" > { ! isNull ( searchResults . packages ) && ( < > { } { ! isEmptyFacets ( ) && ( < Sidebar label = "Filters" className = "d-inline-block d-md-none me-2" wrapperClassName = "px-4" buttonType = { classnames ( 'btn-sm rounded-circle position-relative' , styles . btnMobileFilters , { [ styles . filtersBadge ] : activeFilters , } ) } buttonIcon = { < FaFilter / > } closeButton = { < > { isSearching ? ( < > < span className = "spinner-grow spinner-grow-sm" role = "status" aria - hidden = "true" / > < span className = "ms-2" > Loading ... < /span> < />) : (<>See {searchResults.paginationTotalCount} results</ > ) } < />} leftButton={<> < div className = "d-flex align-items-center" > < IoMdCloseCircleOutline className = { `text-dark ${ styles . resetBtnDecorator }` } /> < button className = "btn btn-link btn-sm p-0 ps-1 text-dark" onClick = { onResetFilters } aria - label = "Reset filters" > Reset < /button> < /div> < />} header={<div className="h6 text-uppercase mb-0 flex-grow-1">Filters</div > } > < div role = "menu" > < Filters forceCollapseList = { props . tsQueryWeb !== currentTsQueryWeb } facets = { searchResults . facets } activeFilters = { props . filters || { } } activeTsQuery = { props . tsQuery } onChange = { onFiltersChange } onResetSomeFilters = { onResetSomeFilters } onTsQueryChange = { onTsQueryChange } deprecated = { props . deprecated } operators = { props . operators } verifiedPublisher = { props . verifiedPublisher } official = { props . official } onDeprecatedChange = { onDeprecatedChange } onOperatorsChange = { onOperatorsChange } onVerifiedPublisherChange = { onVerifiedPublisherChange } onOfficialChange = { onOfficialChange } onResetFilters = { onResetFilters } visibleTitle = { false } device = "mobile" / > < /div> < /Sidebar>)} { ! isSearching && ( < div className = "d-flex flex-column w-100 text-truncate" > < div className = { `text-truncate ${ styles . searchText }` } role = "status" > { parseInt ( searchResults . paginationTotalCount ) > 0 && ( < span className = "pe-1" > { offset + 1 } - { ' ' } { parseInt ( searchResults . paginationTotalCount ) < ctx . prefs . search . limit * props . pageNumber ? searchResults . paginationTotalCount : ctx . prefs . search . limit * props . pageNumber } { ' ' } < span className = "ms-1" > of < /span>{' '} < /span>)} { searchResults . paginationTotalCount } < span className = "ps-1" > results < /span> { props . tsQueryWeb && props . tsQueryWeb !== '' && ( < span className = "d-none d-sm-inline ps-1" > for "<span className=" fw - bold ">{props.tsQueryWeb}</span>" < /span>)} { activeFilters && ( < small className = "d-inline d-lg-none fst-italic ms-1" > ( some filters applied ) < /small>)} < /div> < /div>)} < />)} < /div> < div className = "ms-3" > < div className = "d-flex flex-row" > { } { props . tsQueryWeb && props . tsQueryWeb !== '' && ( < SortOptions activeSort = { props . sort || DEFAULT_SORT } updateSort = { onSortChange } disabled = { isNull ( searchResults . packages ) || searchResults . packages . length === 0 } />)} < div className = "d-none d-sm-flex" > < PaginationLimit limit = { ctx . prefs . search . limit } updateLimit = { onPaginationLimitChange } disabled = { isNull ( searchResults . packages ) || searchResults . packages . length === 0 } /> < /div> < MoreActionsButton / > < /div> < /div> < /div> { activeFilters && ( < div className = "d-none d-lg-inline" > < div className = "d-flex flex-row flex-wrap align-items-center pt-2" > < span className = "me-2 pe-1 mb-2" > Filters : < /span> { props . official && < FilterBadge name = "Only official" onClick = { onOfficialChange } / > } { props . verifiedPublisher && ( < FilterBadge name = "Only verified publishers" onClick = { onVerifiedPublisherChange } / > ) } { ! isUndefined ( props . filters ) && ( < > { Object . keys ( props . filters ) . map ( ( type ) => { const opts = props . filters ! [ type ] ; return ( < Fragment key = { `opts_${ type }` } > { opts . map ( ( opt ) => { const filter = getFilterName ( type , opt ) ; if ( isNull ( filter ) ) return null ; return ( < FilterBadge key = { `btn_${ type }_${ opt }` } type = { filter . key } name = { filter . name } onClick = { ( ) => onFiltersChange ( type , opt , false ) } / > ) ; } ) } < /Fragment>); } ) } < />)} { props . tsQuery && ( < > { props . tsQuery . map ( ( ts ) => { const value = getTsQueryName ( ts ) ; if ( isNull ( value ) ) return null ; return ( < FilterBadge key = { `btn_${ ts }` } type = "Category" name = { value } onClick = { ( ) => onTsQueryChange ( ts , false ) } / > ) ; } ) } < />)} { props . operators && < FilterBadge name = "Only operators" onClick = { onOperatorsChange } / > } { props . deprecated && < FilterBadge name = "Include deprecated" onClick = { onDeprecatedChange } / > } < /div> < /div>)} < /div> < /SubNavbar> < div className = "d-flex position-relative pt-3 pb-3 flex-grow-1" > { ( isSearching || isNull ( searchResults . packages ) ) && < Loading spinnerClassName = "position-fixed top-50" / > } < main role = "main" className = "container-lg px-sm-4 px-lg-0 d-flex flex-row justify-content-between" > { ! isEmptyFacets ( ) && ( < aside className = { `px-xs-0 px-sm-3 px-lg-0 d-none d-md-block position-relative ${ styles . sidebar }` } aria - label = "Filters" > < div className = "me-5" role = "menu" > < Filters forceCollapseList = { props . tsQueryWeb !== currentTsQueryWeb } facets = { searchResults . facets } activeFilters = { props . filters || { } } activeTsQuery = { props . tsQuery } onChange = { onFiltersChange } onResetSomeFilters = { onResetSomeFilters } onTsQueryChange = { onTsQueryChange } deprecated = { props . deprecated } operators = { props . operators } verifiedPublisher = { props . verifiedPublisher } official = { props . official } onDeprecatedChange = { onDeprecatedChange } onOperatorsChange = { onOperatorsChange } onVerifiedPublisherChange = { onVerifiedPublisherChange } onOfficialChange = { onOfficialChange } onResetFilters = { onResetFilters } visibleTitle device = "desktop" / > < /div> < /aside>)} < div className = { classnames ( 'flex-grow-1 mt-3 px-xs-0 px-sm-3 px-lg-0' , styles . list , { [ styles . emptyList ] : isNull ( searchResults . packages ) || searchResults . packages . length === 0 , } ) } > { ! isNull ( searchResults . packages ) && ( < > { searchResults . packages . length === 0 ? ( < NoData issuesLinkVisible = { ! isNull ( apiError ) } > { isNull ( apiError ) ? ( < > We 're sorry! < p className = "h6 mb-0 mt-3 lh-base" > < span > We can 't seem to find any packages that match your search </span> { props . tsQueryWeb && ( < span className = "ps-1" > for "<span className=" fw - bold ">{props.tsQueryWeb}</span>" < /span>)} { ! isEmpty ( props . filters ) && < span className = "ps-1" > with the selected filters < /span>} < /p> < p className = "h6 mb-0 mt-5 lh-base" > You can { ' ' } { ! isEmpty ( props . filters ) ? ( < button className = "btn btn-link text-dark fw-bold py-0 pb-1 px-0" onClick = { onResetFilters } aria - label = "Reset filters" > < u > reset the filters < /u> < /button>) : (<button className="btn btn-link text-dark fw-bold py-0 pb-1 px-0" onClick={() => { history . push ( { pathname : '/packages/search' , search : prepareQueryString ( { pageNumber : 1 , tsQueryWeb : '' , tsQuery : [ ] , filters : { } , } ) , } ) ; } } aria - label = "Browse all packages" > < u > browse all packages < /u> < /button>)} { sampleQueries . length > 0 ? ( < > , try a new search or start with one of the sample queries : < />) : (<> or try a new search.</ > ) } < /p> < div className = "h5 d-flex flex-row align-items-end justify-content-center flex-wrap" > < SampleQueries className = "bg-light text-dark border-secondary text-dark" / > < /div> < />) : (<>{apiError}</ > ) } < /NoData>) : (<> < div className = "mb-2 noFocus" id = "content" tabIndex = { - 1 } aria - label = "Packages list" > < div className = "row" role = "list" > { searchResults . packages . map ( ( item ) => ( < PackageCard key = { item . packageId } package = { item } searchUrlReferer = { { tsQueryWeb : props . tsQueryWeb , tsQuery : props . tsQuery , pageNumber : props . pageNumber , filters : props . filters , deprecated : props . deprecated , operators : props . operators , verifiedPublisher : props . verifiedPublisher , official : props . official , sort : props . sort , } } saveScrollPosition = { saveScrollPosition } / > ) ) } < /div> < /div> < Pagination limit = { ctx . prefs . search . limit } offset = { offset } total = { parseInt ( searchResults . paginationTotalCount ) } active = { props . pageNumber } className = "my-5" onChange = { onPageNumberChange } / > < />)} < />)} < /div> < /main> < /div> < Footer isHidden = { isSearching || isNull ( searchResults . packages ) } /> < />); } ; export default SearchView ;