1import React, { useState, useEffect } from 'react'; 2import debouncePromise from 'debounce-promise'; 3import { cx, css } from '@emotion/css'; 4import { SelectableValue } from '@grafana/data'; 5import { useAsyncFn } from 'react-use'; 6import { InlineLabel, Select, AsyncSelect, Input } from '@grafana/ui'; 7import { useShadowedState } from '../useShadowedState'; 8 9// this file is a simpler version of `grafana-ui / SegmentAsync.tsx` 10// with some changes: 11// 1. click-outside does not select the value. i think it's better to be explicit here. 12// 2. we set a min-width on the select-element to handle cases where the `value` 13// is very short, like "x", and then you click on it and the select opens, 14// and it tries to be as short as "x" and it does not work well. 15 16// NOTE: maybe these changes could be migrated into the SegmentAsync later 17 18type SelVal = SelectableValue<string>; 19 20// when allowCustomValue is true, there is no way to enforce the selectableValue 21// enum-type, so i just go with `string` 22 23type LoadOptions = (filter: string) => Promise<SelVal[]>; 24 25type Props = { 26 value: string; 27 buttonClassName?: string; 28 loadOptions?: LoadOptions; 29 // if filterByLoadOptions is false, 30 // loadOptions is only executed once, 31 // when the select-box opens, 32 // and as you write, the list gets filtered 33 // by the select-box. 34 // if filterByLoadOptions is true, 35 // as you write the loadOptions is executed again and again, 36 // and it is relied on to filter the results. 37 filterByLoadOptions?: boolean; 38 onChange: (v: SelVal) => void; 39 allowCustomValue?: boolean; 40}; 41 42const selectClass = css({ 43 minWidth: '160px', 44}); 45 46type SelProps = { 47 loadOptions: LoadOptions; 48 filterByLoadOptions?: boolean; 49 onClose: () => void; 50 onChange: (v: SelVal) => void; 51 allowCustomValue?: boolean; 52}; 53 54type SelReloadProps = { 55 loadOptions: (filter: string) => Promise<SelVal[]>; 56 onClose: () => void; 57 onChange: (v: SelVal) => void; 58 allowCustomValue?: boolean; 59}; 60 61// when a custom value is written into a select-box, 62// by default the new value is prefixed with "Create:", 63// and that sounds confusing because here we do not create 64// anything. we change this to just be the entered string. 65const formatCreateLabel = (v: string) => v; 66 67const SelReload = ({ loadOptions, allowCustomValue, onChange, onClose }: SelReloadProps): JSX.Element => { 68 // here we rely on the fact that writing text into the <AsyncSelect/> 69 // does not cause a re-render of the current react component. 70 // this way there is only a single render-call, 71 // so there is only a single `debouncedLoadOptions`. 72 // if we want ot make this "re-render safe, 73 // we will have to put the debounced call into an useRef, 74 // and probably have an useEffect 75 const debouncedLoadOptions = debouncePromise(loadOptions, 1000, { leading: true }); 76 return ( 77 <div className={selectClass}> 78 <AsyncSelect 79 menuShouldPortal 80 formatCreateLabel={formatCreateLabel} 81 defaultOptions 82 autoFocus 83 isOpen 84 onCloseMenu={onClose} 85 allowCustomValue={allowCustomValue} 86 loadOptions={debouncedLoadOptions} 87 onChange={onChange} 88 /> 89 </div> 90 ); 91}; 92 93type SelSingleLoadProps = { 94 loadOptions: (filter: string) => Promise<SelVal[]>; 95 onClose: () => void; 96 onChange: (v: SelVal) => void; 97 allowCustomValue?: boolean; 98}; 99 100const SelSingleLoad = ({ loadOptions, allowCustomValue, onChange, onClose }: SelSingleLoadProps): JSX.Element => { 101 const [loadState, doLoad] = useAsyncFn(loadOptions, [loadOptions]); 102 103 useEffect(() => { 104 doLoad(''); 105 }, [doLoad, loadOptions]); 106 107 return ( 108 <div className={selectClass}> 109 <Select 110 menuShouldPortal 111 isLoading={loadState.loading} 112 formatCreateLabel={formatCreateLabel} 113 autoFocus 114 isOpen 115 onCloseMenu={onClose} 116 allowCustomValue={allowCustomValue} 117 options={loadState.value ?? []} 118 onChange={onChange} 119 /> 120 </div> 121 ); 122}; 123 124const Sel = ({ loadOptions, filterByLoadOptions, allowCustomValue, onChange, onClose }: SelProps): JSX.Element => { 125 // unfortunately <Segment/> and <SegmentAsync/> have somewhat different behavior, 126 // so the simplest approach was to just create two separate wrapper-components 127 return filterByLoadOptions ? ( 128 <SelReload loadOptions={loadOptions} allowCustomValue={allowCustomValue} onChange={onChange} onClose={onClose} /> 129 ) : ( 130 <SelSingleLoad 131 loadOptions={loadOptions} 132 allowCustomValue={allowCustomValue} 133 onChange={onChange} 134 onClose={onClose} 135 /> 136 ); 137}; 138 139type InpProps = { 140 initialValue: string; 141 onChange: (newVal: string) => void; 142 onClose: () => void; 143}; 144 145const Inp = ({ initialValue, onChange, onClose }: InpProps): JSX.Element => { 146 const [currentValue, setCurrentValue] = useShadowedState(initialValue); 147 148 return ( 149 <Input 150 autoFocus 151 type="text" 152 spellCheck={false} 153 onBlur={onClose} 154 onKeyDown={(e) => { 155 if (e.key === 'Enter') { 156 onChange(currentValue); 157 } 158 }} 159 onChange={(e) => { 160 setCurrentValue(e.currentTarget.value); 161 }} 162 value={currentValue} 163 /> 164 ); 165}; 166 167const defaultButtonClass = css({ 168 width: 'auto', 169 cursor: 'pointer', 170}); 171 172export const Seg = ({ 173 value, 174 buttonClassName, 175 loadOptions, 176 filterByLoadOptions, 177 allowCustomValue, 178 onChange, 179}: Props): JSX.Element => { 180 const [isOpen, setOpen] = useState(false); 181 if (!isOpen) { 182 const className = cx(defaultButtonClass, buttonClassName); 183 return ( 184 <InlineLabel 185 as="button" 186 className={className} 187 onClick={() => { 188 setOpen(true); 189 }} 190 > 191 {value} 192 </InlineLabel> 193 ); 194 } else { 195 if (loadOptions !== undefined) { 196 return ( 197 <Sel 198 loadOptions={loadOptions} 199 filterByLoadOptions={filterByLoadOptions ?? false} 200 allowCustomValue={allowCustomValue} 201 onChange={(v) => { 202 setOpen(false); 203 onChange(v); 204 }} 205 onClose={() => { 206 setOpen(false); 207 }} 208 /> 209 ); 210 } else { 211 return ( 212 <Inp 213 initialValue={value} 214 onClose={() => { 215 setOpen(false); 216 }} 217 onChange={(v) => { 218 setOpen(false); 219 onChange({ value: v, label: v }); 220 }} 221 /> 222 ); 223 } 224 } 225}; 226