@ -1,9 +1,10 @@
import { cx } from '@emotion/css' ;
import { useVirtualizer } from '@tanstack/react-virtual' ;
import { useCombobox , useMultipleSelection } from 'downshift' ;
import { useCallback , useEffect , use Memo , useState } from 'react' ;
import { useCallback , useMemo , useState } from 'react' ;
import { useStyles2 } from '../../themes' ;
import { t } from '../../utils/i18n' ;
import { Checkbox } from '../Forms/Checkbox' ;
import { Box } from '../Layout/Box/Box' ;
import { Stack } from '../Layout/Stack/Stack' ;
@ -20,17 +21,20 @@ import { itemFilter, itemToString } from './filter';
import { getComboboxStyles , MENU_OPTION_HEIGHT , MENU_OPTION_HEIGHT_DESCRIPTION } from './getComboboxStyles' ;
import { getMultiComboboxStyles } from './getMultiComboboxStyles' ;
import { useComboboxFloat } from './useComboboxFloat' ;
import { useMeasureMulti } from './useMeasureMulti' ;
import { MAX_SHOWN_ITEMS , useMeasureMulti } from './useMeasureMulti' ;
export const ALL_OPTION_VALUE = '__GRAFANA_INTERNAL_MULTICOMBOBOX_ALL_OPTION__' ;
interface MultiComboboxBaseProps < T extends string | number > extends Omit < ComboboxBaseProps < T > , 'value' | 'onChange' > {
value? : T [ ] | Array < ComboboxOption < T > > ;
onChange : ( items? : T [ ] ) = > void ;
enableAllOption? : boolean ;
}
export type MultiComboboxProps < T extends string | number > = MultiComboboxBaseProps < T > & AutoSizeConditionals ;
export const MultiCombobox = < T extends string | number > ( props : MultiComboboxProps < T > ) = > {
const { options , placeholder , onChange , value , width , invalid , loading , disabled } = props ;
const { options , placeholder , onChange , value , width , enableAllOption , invalid , loading , disabled } = props ;
const isAsync = typeof options === 'function' ;
const selectedItems = useMemo ( ( ) = > {
@ -45,15 +49,30 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
const styles = useStyles2 ( getComboboxStyles ) ;
const [ inputValue , setInputValue ] = useState ( '' ) ;
const [ baseItems , baseSetItems ] = useState ( isAsync ? [ ] : options ) ;
const allOptionItem = useMemo ( ( ) = > {
return {
label :
inputValue === ''
? t ( 'multicombobox.all.title' , 'All' )
: t ( 'multicombobox.all.title-filtered' , 'All (filtered)' ) ,
// Type casting needed to make this work when T is a number
value : ALL_OPTION_VALUE ,
} as ComboboxOption < T > ;
} , [ inputValue ] ) ;
const items = useMemo ( ( ) = > baseItems . filter ( itemFilter ( inputValue ) ) , [ baseItems , inputValue ] ) ;
const baseItems = useMemo ( ( ) = > {
return isAsync ? [ ] : enableAllOption ? [ allOptionItem , . . . options ] : options ;
} , [ options , enableAllOption , allOptionItem , isAsync ] ) ;
// TODO: Improve this with async
useEffect ( ( ) = > {
baseSetItems ( isAsync ? [ ] : options ) ;
} , [ options , isAsync ] ) ;
const items = useMemo ( ( ) = > {
const newItems = baseItems . filter ( itemFilter ( inputValue ) ) ;
if ( enableAllOption && newItems . length === 1 && newItems [ 0 ] === allOptionItem ) {
return [ ] ;
}
return newItems ;
} , [ baseItems , inputValue , enableAllOption , allOptionItem ] ) ;
const [ isOpen , setIsOpen ] = useState ( false ) ;
const { inputRef : containerRef , floatingRef , floatStyles , scrollRef } = useComboboxFloat ( items , isOpen ) ;
@ -125,6 +144,25 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
switch ( type ) {
case useCombobox . stateChangeTypes . InputKeyDownEnter :
case useCombobox . stateChangeTypes . ItemClick :
// Handle All functionality
if ( newSelectedItem ? . value === ALL_OPTION_VALUE ) {
const allFilteredSelected = selectedItems . length === items . length - 1 ;
let newSelectedItems = allFilteredSelected && inputValue === '' ? [ ] : baseItems . slice ( 1 ) ;
if ( ! allFilteredSelected && inputValue !== '' ) {
// Select all currently filtered items and deduplicate
newSelectedItems = [ . . . new Set ( [ . . . selectedItems , . . . items . slice ( 1 ) ] ) ] ;
}
if ( allFilteredSelected && inputValue !== '' ) {
// Deselect all currently filtered items
const filteredSet = new Set ( items . slice ( 1 ) . map ( ( item ) = > item . value ) ) ;
newSelectedItems = selectedItems . filter ( ( item ) = > ! filteredSet . has ( item . value ) ) ;
}
onChange ( getComboboxOptionsValues ( newSelectedItems ) ) ;
break ;
}
if ( newSelectedItem ) {
if ( ! isOptionSelected ( newSelectedItem ) ) {
onChange ( getComboboxOptionsValues ( [ . . . selectedItems , newSelectedItem ] ) ) ;
@ -136,6 +174,8 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
case useCombobox . stateChangeTypes . InputChange :
setInputValue ( newInputValue ? ? '' ) ;
break ;
case useCombobox . stateChangeTypes . InputClick :
setIsOpen ( true ) ;
default :
break ;
}
@ -145,13 +185,15 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
const virtualizerOptions = {
count : items.length ,
getScrollElement : ( ) = > scrollRef . current ,
estimateSize : ( index : number ) = > ( items [ index ] . description ? MENU_OPTION_HEIGHT_DESCRIPTION : MENU_OPTION_HEIGHT ) ,
estimateSize : ( index : number ) = >
'description' in items [ index ] ? MENU_OPTION_HEIGHT_DESCRIPTION : MENU_OPTION_HEIGHT ,
overscan : VIRTUAL_OVERSCAN_ITEMS ,
} ;
const rowVirtualizer = useVirtualizer ( virtualizerOptions ) ;
const visibleItems = isOpen ? selectedItems : selectedItems.slice ( 0 , shownItems ) ;
// Selected items that show up in the input field
const visibleItems = isOpen ? selectedItems . slice ( 0 , MAX_SHOWN_ITEMS ) : selectedItems . slice ( 0 , shownItems ) ;
return (
< div ref = { containerRef } >
@ -159,7 +201,6 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
style = { { width : width === 'auto' ? undefined : width } }
className = { cx ( multiStyles . wrapper , { [ multiStyles . disabled ] : disabled } ) }
ref = { measureRef }
onClick = { ( ) = > ! disabled && selectedItems . length > 0 && setIsOpen ( ! isOpen ) }
>
< span className = { multiStyles . pillWrapper } >
{ visibleItems . map ( ( item , index ) = > (
@ -174,7 +215,7 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
{ itemToString ( item ) }
< / ValuePill >
) ) }
{ selectedItems . length > shownItems && ! isOpen && (
{ selectedItems . length > visibleItems . length && (
< Box display = "flex" direction = "row" marginLeft = { 0.5 } gap = { 1 } ref = { counterMeasureRef } >
{ /* eslint-disable-next-line @grafana/no-untranslated-strings */ }
< Text > . . . < / Text >
@ -182,7 +223,7 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
interactive
content = {
< >
{ selectedItems . slice ( shownItems ) . map ( ( item ) = > (
{ selectedItems . slice ( visibleItems . length ) . map ( ( item ) = > (
< div key = { item . value } > { itemToString ( item ) } < / div >
) ) }
< / >
@ -193,15 +234,13 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
< / Box >
) }
< input
className = { cx ( multiStyles . input , {
[ multiStyles . inputClosed ] : ! isOpen && selectedItems . length > 0 ,
} ) }
className = { multiStyles . input }
{ . . . getInputProps (
getDropdownProps ( {
disabled ,
preventKeyAction : isOpen ,
placeholder : selectedItems.length > 0 ? undefined : placeholder ,
onFocus : ( ) = > setIsOpen ( true ) ,
onFocus : ( ) = > ! disabled && setIsOpen ( true ) ,
} )
) }
/ >
@ -227,6 +266,10 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
const itemProps = getItemProps ( { item , index } ) ;
const isSelected = isOptionSelected ( item ) ;
const id = 'multicombobox-option-' + item . value . toString ( ) ;
const isAll = item . value === ALL_OPTION_VALUE ;
const allItemsSelected =
items [ 0 ] ? . value === ALL_OPTION_VALUE && selectedItems . length === items . length - 1 ;
return (
< li
key = { ` ${ item . value } - ${ index } ` }
@ -238,13 +281,23 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
< Stack direction = "row" alignItems = "center" >
< Checkbox
key = { id }
value = { isSelected }
value = { allItemsSelected || isSelected }
indeterminate = { isAll && selectedItems . length > 0 && ! allItemsSelected }
aria - labelledby = { id }
onClick = { ( e ) = > {
e . stopPropagation ( ) ;
} }
/ >
< OptionListItem option = { item } id = { id } / >
< OptionListItem
label = {
isAll
? ( item . label ? ? item . value . toString ( ) ) +
( isAll && inputValue !== '' ? ` ( ${ items . length - 1 } ) ` : '' )
: ( item . label ? ? item . value . toString ( ) )
}
description = { item ? . description }
id = { id }
/ >
< / Stack >
< / li >
) ;
@ -262,31 +315,29 @@ function getSelectedItemsFromValue<T extends string | number>(
value : T [ ] | Array < ComboboxOption < T > > ,
options : Array < ComboboxOption < T > >
) {
if ( ! isComboboxOptions ( value ) ) {
const resultingItems : Array < ComboboxOption < T > | undefined > = [ ] ;
for ( const item of options ) {
for ( const [ index , val ] of value . entries ( ) ) {
if ( val === item . value ) {
resultingItems [ index ] = item ;
}
}
if ( resultingItems . length === value . length && ! resultingItems . includes ( undefined ) ) {
// We found all items for the values
break ;
}
}
if ( isComboboxOptions ( value ) ) {
return value ;
}
const valueMap = new Map ( value . map ( ( val , index ) = > [ val , index ] ) ) ;
const resultingItems : Array < ComboboxOption < T > > = [ ] ;
// Handle values that are not in options
for ( const [ index , val ] of value . entries ( ) ) {
if ( resultingItems [ index ] === undefined ) {
resultingItems [ index ] = { value : val } ;
}
for ( const option of options ) {
const index = valueMap . get ( option . value ) ;
if ( index !== undefined ) {
resultingItems [ index ] = option ;
valueMap . delete ( option . value ) ;
}
if ( valueMap . size === 0 ) {
// We found all values
break ;
}
return resultingItems . filter ( ( item ) = > item !== undefined ) ; // TODO: Not actually needed, but TS complains
}
return value ;
// Handle items that are not in options
for ( const [ val , index ] of valueMap ) {
resultingItems [ index ] = { value : val } ;
}
return resultingItems ;
}
function isComboboxOptions < T extends string | number > (