import * as React from 'react' ; import styles from '../SearchBoxContainer.module.scss' ; import { ISearchBoxAutoCompleteState } from './ISearchBoxAutoCompleteState' ; import { ISearchBoxAutoCompleteProps } from './ISearchBoxAutoCompleteProps' ; import { Spinner , SpinnerSize , FocusZone , FocusZoneDirection , SearchBox , IconButton , Label , Icon , IconType , ISearchBox } from 'office-ui-fabric-react' ; import { ITheme } from 'office-ui-fabric-react/lib/Styling' ; import { isEqual , debounce } from '@microsoft/sp-lodash-subset' ; import { ISuggestion } from '@pnp/modern-search-extensibility' ; import * as webPartStrings from 'SearchBoxWebPartStrings' ; import * as DOMPurify from 'dompurify' ; const SUGGESTION_CHAR_COUNT_TRIGGER = 2 ; const SUGGESTION_UPDATE_DEBOUNCE_DELAY = 200 ; export default class SearchBoxAutoComplete extends React . Component < ISearchBoxAutoCompleteProps , ISearchBoxAutoCompleteState > { private _onChangeDebounced = null ; private _containerElemRef : React . RefObject < any > = null ; public constructor ( props ) { super ( props ) ; this . state = { proposedQuerySuggestions : [ ] , zeroTermQuerySuggestions : [ ] , hasRetrievedZeroTermSuggestions : false , isRetrievingZeroTermSuggestions : false , isRetrievingSuggestions : false , searchInputValue : ( props . inputValue ) ? decodeURIComponent ( props . inputValue ) : '' , termToSuggestFrom : null , errorMessage : null , isSearchExecuted : false } ; this . _updateQuerySuggestions = this . _updateQuerySuggestions . bind ( this ) ; this . _selectQuerySuggestion = this . _selectQuerySuggestion . bind ( this ) ; this . _handleOnClear = this . _handleOnClear . bind ( this ) ; this . _containerElemRef = React . createRef ( ) ; } private _renderSuggestionGroups ( ) { let renderSuggestions = null ; if ( this . state . proposedQuerySuggestions . length > 0 ) { const suggestionGroups = this . state . proposedQuerySuggestions . reduce < { [ key ] : { groupName : string ; suggestions : { suggestion : ISuggestion ; index : number ; } [ ] ; } ; } > ( ( groups , suggestion , index ) => { const groupName = suggestion && suggestion . groupName ? suggestion . groupName . trim ( ) : webPartStrings . PropertyPane . QuerySuggestionsGroup . DefaultSuggestionGroupName ; if ( ! groups [ groupName ] ) { groups [ groupName ] = { groupName , suggestions : [ ] } ; } groups [ groupName ] . suggestions . push ( { suggestion , index } ) ; return groups ; } , { } ) ; let indexIncrementer = - 1 ; const renderedSuggestionGroups = Object . keys ( suggestionGroups ) . map ( groupName => { const currentGroup = suggestionGroups [ groupName ] ; let renderedSuggestions = [ ] ; currentGroup . suggestions . forEach ( ( item , i ) => { if ( i < this . props . numberOfSuggestionsPerGroup ) { indexIncrementer ++ ; renderedSuggestions . push ( this . _renderSuggestion ( item . suggestion , indexIncrementer ) ) ; } } ) ; return ( < > < Label className = { styles . suggestionGroupName } > { groupName } < /Label> < div > { renderedSuggestions } < /div> < />); } ) ; renderSuggestions = < div className = { styles . suggestionPanel } > { renderedSuggestionGroups } < /div>; } return renderSuggestions ; } private _renderSuggestion ( suggestion , suggestionIndex ) { const thisComponent = this ; const suggestionInner = < > < div className = { styles . suggestionContainer } > < div className = { styles . suggestionIcon } > { suggestion . iconSrc && < img src = { suggestion . iconSrc } />} < /div> < div className = { styles . suggestionContent } > < span className = { styles . suggestionDisplayText } dangerouslySetInnerHTML = { { __html : DOMPurify . default . sanitize ( suggestion . displayText ) } } > < /span> < span className = { styles . suggestionDescription } > { suggestion . description ? suggestion . description : "" } < /span> < /div> < /div> < div className = { styles . suggestionAction } > { suggestion . targetUrl && ( < Icon iconName = 'OpenInNewWindow' iconType = { IconType . default } / > ) } < /div> < />; const baseProps = { key : suggestionIndex , title : suggestion . hoverText ? suggestion . hoverText : "" , className : styles . suggestionItem , 'data-is-focusable' : true , onClick : ( ) => thisComponent . _selectQuerySuggestion ( suggestion , ! ! suggestion . targetUrl ) } ; return ( ! ! suggestion . targetUrl ? < a { ... baseProps } href = { suggestion . targetUrl } target = "_blank" data - interception = "off" > { suggestionInner } < /a> : < div { ... baseProps } > { suggestionInner } < /div>); } private _renderLoadingIndicator ( ) { return ( < div className = { styles . suggestionPanel } > < div className = { styles . loadingIndicator } > < Spinner size = { SpinnerSize . small } /> < /div> < /div>); } private async _updateQuerySuggestions ( inputValue ) { const trimmedInputValue = inputValue ? inputValue . trim ( ) : "" ; if ( trimmedInputValue && trimmedInputValue . length >= SUGGESTION_CHAR_COUNT_TRIGGER ) { try { this . setState ( { isRetrievingSuggestions : true , errorMessage : null , proposedQuerySuggestions : [ ] , } ) ; const allProviderPromises = this . props . suggestionProviders . map ( async ( provider ) => { let suggestions = await provider . getSuggestions ( trimmedInputValue ) ; if ( ! this . state . isSearchExecuted && suggestions . length > 0 && ( ! this . state . termToSuggestFrom || inputValue === this . state . searchInputValue ) ) { this . setState ( { proposedQuerySuggestions : this . state . proposedQuerySuggestions . concat ( suggestions ) , termToSuggestFrom : inputValue , isRetrievingSuggestions : false } ) ; } } ) ; Promise . all ( allProviderPromises ) . then ( ( ) => { if ( this . state . isRetrievingSuggestions ) { this . setState ( { isRetrievingSuggestions : false } ) ; } } ) ; } catch ( error ) { this . setState ( { errorMessage : error . message , proposedQuerySuggestions : [ ] , isRetrievingSuggestions : false } ) ; } } else { try { if ( this . state . hasRetrievedZeroTermSuggestions ) { this . setState ( { errorMessage : null , proposedQuerySuggestions : trimmedInputValue . length === 0 ? this . state . zeroTermQuerySuggestions : [ ] , isRetrievingSuggestions : false } ) ; } else { await this . _ensureZeroTermQuerySuggestions ( ) ; } } catch ( error ) { this . setState ( { errorMessage : error . message , proposedQuerySuggestions : [ ] , isRetrievingSuggestions : false } ) ; } } } private async _ensureZeroTermQuerySuggestions ( forceUpdate = false ) { if ( ( ! this . state . hasRetrievedZeroTermSuggestions && ! this . state . isRetrievingZeroTermSuggestions ) || forceUpdate ) { if ( this . props . suggestionProviders && this . props . suggestionProviders . some ( sgp => sgp . isZeroTermSuggestionsEnabled ) ) { this . setState ( { zeroTermQuerySuggestions : [ ] , isRetrievingZeroTermSuggestions : true , } ) ; const allZeroTermSuggestions = await Promise . all ( this . props . suggestionProviders . map ( async ( provider ) : Promise < ISuggestion [ ] > => { let zeroTermSuggestions = [ ] ; if ( provider && provider . isZeroTermSuggestionsEnabled ) { zeroTermSuggestions = await provider . getZeroTermSuggestions ( ) ; } return zeroTermSuggestions ; } ) ) ; const mergedSuggestions = allZeroTermSuggestions . reduce ( ( allSuggestions , suggestions ) => allSuggestions . concat ( suggestions ) , [ ] ) ; this . setState ( { hasRetrievedZeroTermSuggestions : true , isRetrievingZeroTermSuggestions : false , zeroTermQuerySuggestions : mergedSuggestions , } ) ; } else { this . setState ( { zeroTermQuerySuggestions : [ ] , hasRetrievedZeroTermSuggestions : true , } ) ; } } } private _selectQuerySuggestion ( suggestion , isClicked = false ) { const termToSuggestFromIndex = this . state . searchInputValue . indexOf ( this . state . termToSuggestFrom ) ; let replacedSearchInputvalue = this . _replaceAt ( this . state . searchInputValue , termToSuggestFromIndex , suggestion . displayText ) ; replacedSearchInputvalue = replacedSearchInputvalue . replace ( /(<B>|<\/B>)/g , "" ) ; if ( suggestion . onSuggestionSelected ) { try { suggestion . onSuggestionSelected ( suggestion ) ; } catch ( error ) { console . log ( `Error occurred while executing custom onSuggestionSeleted() handler. ${ error }` ) ; } } if ( ! suggestion . targetUrl ) { this . setState ( { searchInputValue : replacedSearchInputvalue , proposedQuerySuggestions : [ ] } , ( ) => this . props . onSearch ( this . state . searchInputValue ) ) ; } else { if ( suggestion . targetUrl && ! isClicked ) { window . open ( suggestion . targetUrl , '_blank' ) ; } this . props . onSearch ( '' , true ) ; } this . _clearSuggestions ( ) ; } private _replaceAt ( string , index , replace ) { return string . substring ( 0 , index ) + replace ; } private _clearSuggestions = ( ) => { if ( this . state . proposedQuerySuggestions . length > 0 ) { this . setState ( { proposedQuerySuggestions : [ ] } ) ; } } ; private _showZeroTermSuggestions = ( ) => { if ( this . state . hasRetrievedZeroTermSuggestions && this . state . zeroTermQuerySuggestions . length > 0 ) { if ( ! isEqual ( this . state . proposedQuerySuggestions , this . state . zeroTermQuerySuggestions ) ) { this . setState ( { proposedQuerySuggestions : this . state . zeroTermQuerySuggestions } ) ; } } } ; private _handleOnFocus = ( ) => { if ( ! this . state . searchInputValue ) { this . _showZeroTermSuggestions ( ) ; } } ; private _handleOnSearch = ( ) => { this . props . onSearch ( this . state . searchInputValue ) ; this . setState ( { isSearchExecuted : true , proposedQuerySuggestions : [ ] } ) ; } ; private _handleOnClear = ( ) => { this . _updateQuerySuggestions ( '' ) ; this . setState ( { searchInputValue : "" , } ) ; this . props . onSearch ( '' , true ) ; } ; private _handleClickOutsideContainer = ( event ) => { if ( ! this . _containerElemRef . current . contains ( event . target ) ) { this . _clearSuggestions ( ) ; } } ; public componentDidMount ( ) { this . _ensureZeroTermQuerySuggestions ( ) ; document . addEventListener ( 'click' , this . _handleClickOutsideContainer ) ; } public componentWillUnmount ( ) { document . removeEventListener ( 'click' , this . _handleClickOutsideContainer ) ; } public componentDidUpdate ( prevProps ) { if ( prevProps . suggestionProviders . length !== this . props . suggestionProviders . length || ! isEqual ( prevProps . suggestionProviders , this . props . suggestionProviders ) ) { this . _ensureZeroTermQuerySuggestions ( true ) ; } if ( ! isEqual ( prevProps . inputValue , this . props . inputValue ) ) { this . setState ( { searchInputValue : this . props . inputValue } ) ; } } public render ( ) { const showLoadingIndicator = ( this . state . isRetrievingSuggestions && this . state . proposedQuerySuggestions . length === 0 ) || ( this . state . isRetrievingZeroTermSuggestions && ! this . state . searchInputValue ) ; try { const parentStackingContext = this . props . domElement . closest ( ".Canvas-slideUpIn" ) ; if ( parentStackingContext ) { parentStackingContext . classList . add ( styles . parentStackingCtx ) ; } } catch ( error ) { } let searchBoxRef = React . createRef < ISearchBox > ( ) ; return ( < div ref = { this . _containerElemRef } > < FocusZone direction = { FocusZoneDirection . bidirectional } isCircularNavigation = { true } defaultActiveElement = { `.ms-SearchBox.${ styles . searchTextField }` } > < div className = { styles . searchBoxWrapper } > < SearchBox componentRef = { searchBoxRef } placeholder = { this . props . placeholderText ? this . props . placeholderText : webPartStrings . SearchBox . DefaultPlaceholder } ariaLabel = { this . props . placeholderText ? this . props . placeholderText : webPartStrings . SearchBox . DefaultPlaceholder } theme = { this . props . themeVariant as ITheme } className = { styles . searchTextField } value = { this . state . searchInputValue } autoComplete = "off" onChange = { ( event ) => { if ( ! this . _onChangeDebounced ) { this . _onChangeDebounced = debounce ( ( newValue ) => { this . _updateQuerySuggestions ( newValue ) ; } , SUGGESTION_UPDATE_DEBOUNCE_DELAY ) ; } this . _onChangeDebounced ( event . currentTarget . value ) ; this . setState ( { searchInputValue : event . currentTarget . value , isRetrievingSuggestions : true , isSearchExecuted : false , } ) ; } } onFocus = { this . _handleOnFocus } onSearch = { this . _handleOnSearch } onClear = { ( ) => { this . _handleOnClear ( ) ; searchBoxRef . current . focus ( ) ; } } /> < div className = { styles . searchButton } > { this . state . searchInputValue && < IconButton onClick = { this . _handleOnSearch } iconProps = { { iconName : 'Forward' } } ariaLabel = { webPartStrings . SearchBox . SearchButtonLabel } / > } < /div> < /div> { ! this . state . isSearchExecuted && ( ! ! this . state . searchInputValue || this . state . proposedQuerySuggestions . length > 0 ) ? showLoadingIndicator ? this . _renderLoadingIndicator ( ) : this . _renderSuggestionGroups ( ) : null } < /FocusZone> < /div>); } }