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