selectControl.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  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: `inset ${theme.dropShadowLight}`,
  126. },
  127. borderRadius: theme.borderRadius,
  128. transition: 'border 0.1s linear',
  129. alignItems: 'center',
  130. minHeight: '40px',
  131. '&:hover': {
  132. borderColor: theme.border,
  133. },
  134. ...(state.isFocused && {
  135. border: `1px solid ${theme.border}`,
  136. boxShadow: 'rgba(209, 202, 216, 0.5) 0 0 0 3px',
  137. }),
  138. ...(state.menuIsOpen && {
  139. borderBottomLeftRadius: '0',
  140. borderBottomRightRadius: '0',
  141. boxShadow: 'none',
  142. }),
  143. ...(state.isDisabled && {
  144. borderColor: theme.border,
  145. background: theme.backgroundSecondary,
  146. color: theme.disabled,
  147. cursor: 'not-allowed',
  148. }),
  149. ...(!state.isSearchable && {
  150. cursor: 'pointer',
  151. }),
  152. }),
  153. menu: (provided: React.CSSProperties) => ({
  154. ...provided,
  155. zIndex: theme.zIndex.dropdown,
  156. marginTop: '-1px',
  157. background: theme.background,
  158. border: `1px solid ${theme.border}`,
  159. borderRadius: `0 0 ${theme.borderRadius} ${theme.borderRadius}`,
  160. borderTop: `1px solid ${theme.border}`,
  161. boxShadow: theme.dropShadowLight,
  162. }),
  163. option: (provided: React.CSSProperties, state: any) => ({
  164. ...provided,
  165. lineHeight: '1.5',
  166. fontSize: theme.fontSizeMedium,
  167. cursor: 'pointer',
  168. color: state.isFocused
  169. ? theme.textColor
  170. : state.isSelected
  171. ? theme.background
  172. : theme.textColor,
  173. backgroundColor: state.isFocused
  174. ? theme.hover
  175. : state.isSelected
  176. ? theme.active
  177. : 'transparent',
  178. '&:active': {
  179. backgroundColor: theme.active,
  180. },
  181. }),
  182. valueContainer: (provided: React.CSSProperties) => ({
  183. ...provided,
  184. alignItems: 'center',
  185. }),
  186. input: (provided: React.CSSProperties) => ({
  187. ...provided,
  188. color: theme.formText,
  189. }),
  190. singleValue: (provided: React.CSSProperties) => ({
  191. ...provided,
  192. color: theme.formText,
  193. }),
  194. placeholder: (provided: React.CSSProperties) => ({
  195. ...provided,
  196. color: theme.formPlaceholder,
  197. }),
  198. multiValue: (provided: React.CSSProperties) => ({
  199. ...provided,
  200. color: '#007eff',
  201. backgroundColor: '#ebf5ff',
  202. borderRadius: '2px',
  203. border: '1px solid #c2e0ff',
  204. display: 'flex',
  205. }),
  206. multiValueLabel: (provided: React.CSSProperties) => ({
  207. ...provided,
  208. color: '#007eff',
  209. padding: '0',
  210. paddingLeft: '6px',
  211. lineHeight: '1.8',
  212. }),
  213. multiValueRemove: () => ({
  214. cursor: 'pointer',
  215. alignItems: 'center',
  216. borderLeft: '1px solid #c2e0ff',
  217. borderRadius: '0 2px 2px 0',
  218. display: 'flex',
  219. padding: '0 4px',
  220. marginLeft: '4px',
  221. '&:hover': {
  222. color: '#6284b9',
  223. background: '#cce5ff',
  224. },
  225. }),
  226. indicatorsContainer: () => ({
  227. display: 'grid',
  228. gridAutoFlow: 'column',
  229. gridGap: '2px',
  230. marginRight: '6px',
  231. }),
  232. clearIndicator: indicatorStyles,
  233. dropdownIndicator: indicatorStyles,
  234. loadingIndicator: indicatorStyles,
  235. groupHeading: (provided: React.CSSProperties) => ({
  236. ...provided,
  237. lineHeight: '1.5',
  238. fontWeight: 600,
  239. backgroundColor: theme.backgroundSecondary,
  240. color: theme.textColor,
  241. marginBottom: 0,
  242. padding: `${space(1)} ${space(1.5)}`,
  243. }),
  244. group: (provided: React.CSSProperties) => ({
  245. ...provided,
  246. padding: 0,
  247. }),
  248. };
  249. const getFieldLabelStyle = (label?: string) => ({
  250. ':before': {
  251. content: `"${label}"`,
  252. color: theme.gray300,
  253. fontWeight: 600,
  254. },
  255. });
  256. const {
  257. async,
  258. creatable,
  259. options,
  260. choices,
  261. clearable,
  262. components,
  263. styles,
  264. value,
  265. inFieldLabel,
  266. ...rest
  267. } = props;
  268. // Compatibility with old select2 API
  269. const choicesOrOptions =
  270. convertFromSelect2Choices(typeof choices === 'function' ? choices(props) : choices) ||
  271. options;
  272. // It's possible that `choicesOrOptions` does not exist (e.g. in the case of AsyncSelect)
  273. let mappedValue = value;
  274. if (choicesOrOptions) {
  275. /**
  276. * Value is expected to be object like the options list, we map it back from the options list.
  277. * Note that if the component doesn't have options or choices passed in
  278. * because the select component fetches the options finding the mappedValue will fail
  279. * and the component won't work
  280. */
  281. let flatOptions: any[] = [];
  282. if (isGroupedOptions<OptionType>(choicesOrOptions)) {
  283. flatOptions = choicesOrOptions.flatMap(option => option.options);
  284. } else {
  285. // @ts-ignore The types used in react-select generics (OptionType) don't
  286. // line up well with our option type (SelectValue). We need to do more work
  287. // to get these types to align.
  288. flatOptions = choicesOrOptions.flatMap(option => option);
  289. }
  290. mappedValue =
  291. props.multiple && Array.isArray(value)
  292. ? value.map(val => flatOptions.find(option => option.value === val))
  293. : flatOptions.find(opt => opt.value === value) || value;
  294. }
  295. // Override the default style with in-field labels if they are provided
  296. const inFieldLabelStyles = {
  297. singleValue: (base: React.CSSProperties) => ({
  298. ...base,
  299. ...getFieldLabelStyle(inFieldLabel),
  300. }),
  301. placeholder: (base: React.CSSProperties) => ({
  302. ...base,
  303. ...getFieldLabelStyle(inFieldLabel),
  304. }),
  305. };
  306. const labelOrDefaultStyles = inFieldLabel
  307. ? mergeStyles(defaultStyles, inFieldLabelStyles)
  308. : defaultStyles;
  309. // Allow the provided `styles` prop to override default styles using the same
  310. // function interface provided by react-styled. This ensures the `provided`
  311. // styles include our overridden default styles
  312. const mappedStyles = styles
  313. ? mergeStyles(labelOrDefaultStyles, styles)
  314. : labelOrDefaultStyles;
  315. const replacedComponents = {
  316. ClearIndicator,
  317. DropdownIndicator,
  318. MultiValueRemove,
  319. LoadingIndicator: SelectLoadingIndicator,
  320. IndicatorSeparator: null,
  321. };
  322. return (
  323. <SelectPicker<OptionType>
  324. styles={mappedStyles}
  325. components={{...replacedComponents, ...components}}
  326. async={async}
  327. creatable={creatable}
  328. isClearable={clearable}
  329. backspaceRemovesValue={clearable}
  330. value={mappedValue}
  331. isMulti={props.multiple || props.multi}
  332. isDisabled={props.isDisabled || props.disabled}
  333. options={options || (choicesOrOptions as OptionsType<OptionType>)}
  334. openMenuOnFocus={props.openMenuOnFocus === undefined ? true : props.openMenuOnFocus}
  335. {...rest}
  336. />
  337. );
  338. }
  339. type PickerProps<OptionType> = ControlProps<OptionType> & {
  340. /**
  341. * Enable async option loading.
  342. */
  343. async?: boolean;
  344. /**
  345. * Enable 'create' mode which allows values to be created inline.
  346. */
  347. creatable?: boolean;
  348. /**
  349. * Enable 'clearable' which allows values to be removed.
  350. */
  351. clearable?: boolean;
  352. };
  353. function SelectPicker<OptionType>({
  354. async,
  355. creatable,
  356. forwardedRef,
  357. ...props
  358. }: PickerProps<OptionType>) {
  359. // Pick the right component to use
  360. // Using any here as react-select types also use any
  361. let Component: React.ComponentType<any> | undefined;
  362. if (async && creatable) {
  363. Component = AsyncCreatable;
  364. } else if (async && !creatable) {
  365. Component = Async;
  366. } else if (creatable) {
  367. Component = Creatable;
  368. } else {
  369. Component = ReactSelect;
  370. }
  371. return <Component ref={forwardedRef} {...props} />;
  372. }
  373. // The generics need to be filled here as forwardRef can't expose generics.
  374. const RefForwardedSelectControl = React.forwardRef<
  375. ReactSelect<GeneralSelectValue>,
  376. ControlProps<GeneralSelectValue>
  377. >(function RefForwardedSelectControl(props, ref) {
  378. return <SelectControl forwardedRef={ref as any} {...props} />;
  379. });
  380. export default RefForwardedSelectControl;