Files
2021-05-31 10:09:29 +02:00

224 lines
6.2 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import debouncePromise from 'debounce-promise';
import { cx, css } from '@emotion/css';
import { SelectableValue } from '@grafana/data';
import { useAsyncFn } from 'react-use';
import { InlineLabel, Select, AsyncSelect, Input } from '@grafana/ui';
import { useShadowedState } from '../useShadowedState';
// this file is a simpler version of `grafana-ui / SegmentAsync.tsx`
// with some changes:
// 1. click-outside does not select the value. i think it's better to be explicit here.
// 2. we set a min-width on the select-element to handle cases where the `value`
// is very short, like "x", and then you click on it and the select opens,
// and it tries to be as short as "x" and it does not work well.
// NOTE: maybe these changes could be migrated into the SegmentAsync later
type SelVal = SelectableValue<string>;
// when allowCustomValue is true, there is no way to enforce the selectableValue
// enum-type, so i just go with `string`
type LoadOptions = (filter: string) => Promise<SelVal[]>;
type Props = {
value: string;
buttonClassName?: string;
loadOptions?: LoadOptions;
// if filterByLoadOptions is false,
// loadOptions is only executed once,
// when the select-box opens,
// and as you write, the list gets filtered
// by the select-box.
// if filterByLoadOptions is true,
// as you write the loadOptions is executed again and again,
// and it is relied on to filter the results.
filterByLoadOptions?: boolean;
onChange: (v: SelVal) => void;
allowCustomValue?: boolean;
};
const selectClass = css({
minWidth: '160px',
});
type SelProps = {
loadOptions: LoadOptions;
filterByLoadOptions?: boolean;
onClose: () => void;
onChange: (v: SelVal) => void;
allowCustomValue?: boolean;
};
type SelReloadProps = {
loadOptions: (filter: string) => Promise<SelVal[]>;
onClose: () => void;
onChange: (v: SelVal) => void;
allowCustomValue?: boolean;
};
// when a custom value is written into a select-box,
// by default the new value is prefixed with "Create:",
// and that sounds confusing because here we do not create
// anything. we change this to just be the entered string.
const formatCreateLabel = (v: string) => v;
const SelReload = ({ loadOptions, allowCustomValue, onChange, onClose }: SelReloadProps): JSX.Element => {
// here we rely on the fact that writing text into the <AsyncSelect/>
// does not cause a re-render of the current react component.
// this way there is only a single render-call,
// so there is only a single `debouncedLoadOptions`.
// if we want ot make this "re-render safe,
// we will have to put the debounced call into an useRef,
// and probably have an useEffect
const debouncedLoadOptions = debouncePromise(loadOptions, 1000, { leading: true });
return (
<div className={selectClass}>
<AsyncSelect
formatCreateLabel={formatCreateLabel}
defaultOptions
autoFocus
isOpen
onCloseMenu={onClose}
allowCustomValue={allowCustomValue}
loadOptions={debouncedLoadOptions}
onChange={onChange}
/>
</div>
);
};
type SelSingleLoadProps = {
loadOptions: (filter: string) => Promise<SelVal[]>;
onClose: () => void;
onChange: (v: SelVal) => void;
allowCustomValue?: boolean;
};
const SelSingleLoad = ({ loadOptions, allowCustomValue, onChange, onClose }: SelSingleLoadProps): JSX.Element => {
const [loadState, doLoad] = useAsyncFn(loadOptions, [loadOptions]);
useEffect(() => {
doLoad();
}, [doLoad, loadOptions]);
return (
<div className={selectClass}>
<Select
isLoading={loadState.loading}
formatCreateLabel={formatCreateLabel}
autoFocus
isOpen
onCloseMenu={onClose}
allowCustomValue={allowCustomValue}
options={loadState.value ?? []}
onChange={onChange}
/>
</div>
);
};
const Sel = ({ loadOptions, filterByLoadOptions, allowCustomValue, onChange, onClose }: SelProps): JSX.Element => {
// unfortunately <Segment/> and <SegmentAsync/> have somewhat different behavior,
// so the simplest approach was to just create two separate wrapper-components
return filterByLoadOptions ? (
<SelReload loadOptions={loadOptions} allowCustomValue={allowCustomValue} onChange={onChange} onClose={onClose} />
) : (
<SelSingleLoad
loadOptions={loadOptions}
allowCustomValue={allowCustomValue}
onChange={onChange}
onClose={onClose}
/>
);
};
type InpProps = {
initialValue: string;
onChange: (newVal: string) => void;
onClose: () => void;
};
const Inp = ({ initialValue, onChange, onClose }: InpProps): JSX.Element => {
const [currentValue, setCurrentValue] = useShadowedState(initialValue);
return (
<Input
autoFocus
type="text"
spellCheck={false}
onBlur={onClose}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onChange(currentValue);
}
}}
onChange={(e) => {
setCurrentValue(e.currentTarget.value);
}}
value={currentValue}
/>
);
};
const defaultButtonClass = css({
width: 'auto',
cursor: 'pointer',
});
export const Seg = ({
value,
buttonClassName,
loadOptions,
filterByLoadOptions,
allowCustomValue,
onChange,
}: Props): JSX.Element => {
const [isOpen, setOpen] = useState(false);
if (!isOpen) {
const className = cx(defaultButtonClass, buttonClassName);
return (
<InlineLabel
as="button"
className={className}
onClick={() => {
setOpen(true);
}}
>
{value}
</InlineLabel>
);
} else {
if (loadOptions !== undefined) {
return (
<Sel
loadOptions={loadOptions}
filterByLoadOptions={filterByLoadOptions ?? false}
allowCustomValue={allowCustomValue}
onChange={(v) => {
setOpen(false);
onChange(v);
}}
onClose={() => {
setOpen(false);
}}
/>
);
} else {
return (
<Inp
initialValue={value}
onClose={() => {
setOpen(false);
}}
onChange={(v) => {
setOpen(false);
onChange({ value: v, label: v });
}}
/>
);
}
}
};