selectControl.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  1. import * as React from 'react';
  2. import ReactSelect, {
  3. components as selectComponents,
  4. GroupedOptionsType,
  5. mergeStyles,
  6. OptionsType,
  7. Props as ReactSelectProps,
  8. StylesConfig,
  9. } from 'react-select';
  10. import Async from 'react-select/async';
  11. import AsyncCreatable from 'react-select/async-creatable';
  12. import Creatable from 'react-select/creatable';
  13. import {useTheme} from '@emotion/react';
  14. import LoadingIndicator from 'sentry/components/loadingIndicator';
  15. import {IconChevron, IconClose} from 'sentry/icons';
  16. import space from 'sentry/styles/space';
  17. import {Choices, SelectValue} from 'sentry/types';
  18. import convertFromSelect2Choices from 'sentry/utils/convertFromSelect2Choices';
  19. import Option from './selectOption';
  20. function isGroupedOptions<OptionType>(
  21. maybe:
  22. | ReturnType<typeof convertFromSelect2Choices>
  23. | GroupedOptionsType<OptionType>
  24. | OptionType[]
  25. | OptionsType<OptionType>
  26. ): maybe is GroupedOptionsType<OptionType> {
  27. if (!maybe || maybe.length === 0) {
  28. return false;
  29. }
  30. return (maybe as GroupedOptionsType<OptionType>)[0].options !== undefined;
  31. }
  32. const ClearIndicator = (
  33. props: React.ComponentProps<typeof selectComponents.ClearIndicator>
  34. ) => (
  35. <selectComponents.ClearIndicator {...props}>
  36. <IconClose size="10px" />
  37. </selectComponents.ClearIndicator>
  38. );
  39. const DropdownIndicator = (
  40. props: React.ComponentProps<typeof selectComponents.DropdownIndicator>
  41. ) => (
  42. <selectComponents.DropdownIndicator {...props}>
  43. <IconChevron direction="down" size="14px" />
  44. </selectComponents.DropdownIndicator>
  45. );
  46. const MultiValueRemove = (
  47. props: React.ComponentProps<typeof selectComponents.MultiValueRemove>
  48. ) => (
  49. <selectComponents.MultiValueRemove {...props}>
  50. <IconClose size="8px" />
  51. </selectComponents.MultiValueRemove>
  52. );
  53. const SelectLoadingIndicator = () => (
  54. <LoadingIndicator mini size={20} style={{height: 20, width: 20}} />
  55. );
  56. export type ControlProps<OptionType = GeneralSelectValue> = Omit<
  57. ReactSelectProps<OptionType>,
  58. 'onChange' | 'value'
  59. > & {
  60. /**
  61. * Backwards compatible shim to work with select2 style choice type.
  62. */
  63. choices?: Choices | ((props: ControlProps<OptionType>) => Choices);
  64. /**
  65. * Set to true to prefix selected values with content
  66. */
  67. inFieldLabel?: string;
  68. /**
  69. * Used by MultiSelectControl.
  70. */
  71. multiple?: boolean;
  72. /**
  73. * Handler for changes. Narrower than the types in react-select.
  74. */
  75. onChange?: (value?: OptionType | null) => void;
  76. /**
  77. * Show line dividers between options
  78. */
  79. showDividers?: boolean;
  80. /**
  81. * Unlike react-select which expects an OptionType as its value
  82. * we accept the option.value and resolve the option object.
  83. * Because this type is embedded in the OptionType generic we
  84. * can't have a good type here.
  85. */
  86. value?: any;
  87. /**
  88. * If false (default), checkmarks/checkboxes will be vertically centered
  89. * wrt the first line of the label text. If true, they will be centered
  90. * wrt the entire height of the option wrap.
  91. */
  92. verticallyCenterCheckWrap?: boolean;
  93. };
  94. /**
  95. * Additional props provided by forwardRef
  96. */
  97. type WrappedControlProps<OptionType> = ControlProps<OptionType> & {
  98. /**
  99. * Ref forwarded into ReactSelect component.
  100. * The any is inherited from react-select.
  101. */
  102. forwardedRef: React.Ref<ReactSelect>;
  103. };
  104. // TODO(ts) The exported component uses forwardRef.
  105. // This means we cannot fill the SelectValue generic
  106. // at the call site. We use `any` here to avoid type errors with select
  107. // controls that have custom option structures
  108. export type GeneralSelectValue = SelectValue<any>;
  109. function SelectControl<OptionType extends GeneralSelectValue = GeneralSelectValue>(
  110. props: WrappedControlProps<OptionType>
  111. ) {
  112. const theme = useTheme();
  113. // TODO(epurkhiser): The loading indicator should probably also be our loading
  114. // indicator.
  115. // Unfortunately we cannot use emotions `css` helper here, since react-select
  116. // *requires* object styles, which the css helper cannot produce.
  117. const indicatorStyles = ({padding: _padding, ...provided}: React.CSSProperties) => ({
  118. ...provided,
  119. padding: '4px',
  120. alignItems: 'center',
  121. cursor: 'pointer',
  122. color: theme.subText,
  123. });
  124. const defaultStyles: StylesConfig = {
  125. control: (_, state: any) => ({
  126. height: '100%',
  127. lineHeight: theme.text.lineHeightBody,
  128. display: 'flex',
  129. // @ts-ignore Ignore merge errors as only defining the property once
  130. // makes code harder to understand.
  131. ...{
  132. color: theme.formText,
  133. background: theme.background,
  134. border: `1px solid ${theme.border}`,
  135. boxShadow: theme.dropShadowLight,
  136. },
  137. borderRadius: theme.borderRadius,
  138. transition: 'border 0.1s, box-shadow 0.1s',
  139. alignItems: 'center',
  140. minHeight: '40px',
  141. ...(state.isFocused && {
  142. borderColor: theme.focusBorder,
  143. boxShadow: `${theme.focusBorder} 0 0 0 1px`,
  144. }),
  145. ...(state.isDisabled && {
  146. borderColor: theme.border,
  147. background: theme.backgroundSecondary,
  148. color: theme.disabled,
  149. cursor: 'not-allowed',
  150. }),
  151. ...(!state.isSearchable && {
  152. cursor: 'pointer',
  153. }),
  154. }),
  155. menu: (provided: React.CSSProperties) => ({
  156. ...provided,
  157. zIndex: theme.zIndex.dropdown,
  158. background: theme.backgroundElevated,
  159. border: `1px solid ${theme.border}`,
  160. borderRadius: theme.borderRadius,
  161. boxShadow: theme.dropShadowHeavy,
  162. width: 'auto',
  163. minWidth: '100%',
  164. }),
  165. option: (provided: React.CSSProperties) => ({
  166. ...provided,
  167. fontSize: theme.fontSizeMedium,
  168. cursor: 'pointer',
  169. color: theme.textColor,
  170. background: 'transparent',
  171. padding: `0 ${space(0.5)}`,
  172. ':active': {
  173. background: 'transparent',
  174. },
  175. }),
  176. valueContainer: (provided: React.CSSProperties) => ({
  177. ...provided,
  178. alignItems: 'center',
  179. }),
  180. input: (provided: React.CSSProperties) => ({
  181. ...provided,
  182. color: theme.formText,
  183. }),
  184. singleValue: (provided: React.CSSProperties) => ({
  185. ...provided,
  186. color: theme.formText,
  187. }),
  188. placeholder: (provided: React.CSSProperties) => ({
  189. ...provided,
  190. color: theme.formPlaceholder,
  191. }),
  192. multiValue: (provided: React.CSSProperties) => ({
  193. ...provided,
  194. color: '#007eff',
  195. backgroundColor: '#ebf5ff',
  196. borderRadius: '2px',
  197. border: '1px solid #c2e0ff',
  198. display: 'flex',
  199. }),
  200. multiValueLabel: (provided: React.CSSProperties) => ({
  201. ...provided,
  202. color: '#007eff',
  203. padding: '0',
  204. paddingLeft: '6px',
  205. lineHeight: '1.8',
  206. }),
  207. multiValueRemove: () => ({
  208. cursor: 'pointer',
  209. alignItems: 'center',
  210. borderLeft: '1px solid #c2e0ff',
  211. borderRadius: '0 2px 2px 0',
  212. display: 'flex',
  213. padding: '0 4px',
  214. marginLeft: '4px',
  215. '&:hover': {
  216. color: '#6284b9',
  217. background: '#cce5ff',
  218. },
  219. }),
  220. indicatorsContainer: () => ({
  221. display: 'grid',
  222. gridAutoFlow: 'column',
  223. gridGap: '2px',
  224. marginRight: '6px',
  225. }),
  226. clearIndicator: indicatorStyles,
  227. dropdownIndicator: indicatorStyles,
  228. loadingIndicator: indicatorStyles,
  229. groupHeading: (provided: React.CSSProperties) => ({
  230. ...provided,
  231. lineHeight: '1.5',
  232. fontWeight: 600,
  233. color: theme.subText,
  234. marginBottom: 0,
  235. padding: `${space(0.5)} ${space(1.5)}`,
  236. }),
  237. group: (provided: React.CSSProperties) => ({
  238. ...provided,
  239. paddingTop: 0,
  240. ':last-of-type': {
  241. paddingBottom: 0,
  242. },
  243. }),
  244. };
  245. const getFieldLabelStyle = (label?: string) => ({
  246. ':before': {
  247. content: `"${label}"`,
  248. color: theme.gray300,
  249. fontWeight: 600,
  250. },
  251. });
  252. const {
  253. async,
  254. creatable,
  255. options,
  256. choices,
  257. clearable,
  258. components,
  259. styles,
  260. value,
  261. inFieldLabel,
  262. ...rest
  263. } = props;
  264. // Compatibility with old select2 API
  265. const choicesOrOptions =
  266. convertFromSelect2Choices(typeof choices === 'function' ? choices(props) : choices) ||
  267. options;
  268. // It's possible that `choicesOrOptions` does not exist (e.g. in the case of AsyncSelect)
  269. let mappedValue = value;
  270. if (choicesOrOptions) {
  271. /**
  272. * Value is expected to be object like the options list, we map it back from the options list.
  273. * Note that if the component doesn't have options or choices passed in
  274. * because the select component fetches the options finding the mappedValue will fail
  275. * and the component won't work
  276. */
  277. let flatOptions: any[] = [];
  278. if (isGroupedOptions<OptionType>(choicesOrOptions)) {
  279. flatOptions = choicesOrOptions.flatMap(option => option.options);
  280. } else {
  281. // @ts-ignore The types used in react-select generics (OptionType) don't
  282. // line up well with our option type (SelectValue). We need to do more work
  283. // to get these types to align.
  284. flatOptions = choicesOrOptions.flatMap(option => option);
  285. }
  286. mappedValue =
  287. props.multiple && Array.isArray(value)
  288. ? value.map(val => flatOptions.find(option => option.value === val))
  289. : flatOptions.find(opt => opt.value === value) || value;
  290. }
  291. // Override the default style with in-field labels if they are provided
  292. const inFieldLabelStyles = {
  293. singleValue: (base: React.CSSProperties) => ({
  294. ...base,
  295. ...getFieldLabelStyle(inFieldLabel),
  296. }),
  297. placeholder: (base: React.CSSProperties) => ({
  298. ...base,
  299. ...getFieldLabelStyle(inFieldLabel),
  300. }),
  301. };
  302. const labelOrDefaultStyles = inFieldLabel
  303. ? mergeStyles(defaultStyles, inFieldLabelStyles)
  304. : defaultStyles;
  305. // Allow the provided `styles` prop to override default styles using the same
  306. // function interface provided by react-styled. This ensures the `provided`
  307. // styles include our overridden default styles
  308. const mappedStyles = styles
  309. ? mergeStyles(labelOrDefaultStyles, styles)
  310. : labelOrDefaultStyles;
  311. const replacedComponents = {
  312. ClearIndicator,
  313. DropdownIndicator,
  314. MultiValueRemove,
  315. LoadingIndicator: SelectLoadingIndicator,
  316. IndicatorSeparator: null,
  317. Option,
  318. };
  319. return (
  320. <SelectPicker<OptionType>
  321. styles={mappedStyles}
  322. components={{...replacedComponents, ...components}}
  323. async={async}
  324. creatable={creatable}
  325. isClearable={clearable}
  326. backspaceRemovesValue={clearable}
  327. value={mappedValue}
  328. isMulti={props.multiple || props.multi}
  329. isDisabled={props.isDisabled || props.disabled}
  330. showDividers={props.showDividers}
  331. options={options || (choicesOrOptions as OptionsType<OptionType>)}
  332. openMenuOnFocus={props.openMenuOnFocus}
  333. blurInputOnSelect={!props.multiple && !props.multi}
  334. closeMenuOnSelect={!(props.multiple || props.multi)}
  335. hideSelectedOptions={false}
  336. tabSelectsValue={false}
  337. {...rest}
  338. />
  339. );
  340. }
  341. type PickerProps<OptionType> = ControlProps<OptionType> & {
  342. /**
  343. * Enable async option loading.
  344. */
  345. async?: boolean;
  346. /**
  347. * Enable 'clearable' which allows values to be removed.
  348. */
  349. clearable?: boolean;
  350. /**
  351. * Enable 'create' mode which allows values to be created inline.
  352. */
  353. creatable?: boolean;
  354. };
  355. function SelectPicker<OptionType>({
  356. async,
  357. creatable,
  358. forwardedRef,
  359. ...props
  360. }: PickerProps<OptionType>) {
  361. // Pick the right component to use
  362. // Using any here as react-select types also use any
  363. let Component: React.ComponentType<any> | undefined;
  364. if (async && creatable) {
  365. Component = AsyncCreatable;
  366. } else if (async && !creatable) {
  367. Component = Async;
  368. } else if (creatable) {
  369. Component = Creatable;
  370. } else {
  371. Component = ReactSelect;
  372. }
  373. return <Component ref={forwardedRef} {...props} />;
  374. }
  375. // The generics need to be filled here as forwardRef can't expose generics.
  376. const RefForwardedSelectControl = React.forwardRef<
  377. ReactSelect<GeneralSelectValue>,
  378. ControlProps<GeneralSelectValue>
  379. >(function RefForwardedSelectControl(props, ref) {
  380. return <SelectControl forwardedRef={ref as any} {...props} />;
  381. });
  382. export default RefForwardedSelectControl;