selectControl.tsx 12 KB

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