Files
2024-02-15 17:01:50 +00:00

517 lines
15 KiB
TypeScript

import {forwardRef, useCallback, useMemo} from 'react';
import type {
GroupedOptionsType,
OptionsType,
OptionTypeBase,
Props as ReactSelectProps,
StylesConfig as ReactSelectStylesConfig,
} from 'react-select';
import ReactSelect, {
components as selectComponents,
createFilter,
mergeStyles,
} from 'react-select';
import Async from 'react-select/async';
import AsyncCreatable from 'react-select/async-creatable';
import Creatable from 'react-select/creatable';
import type {CSSObject} from '@emotion/react';
import {useTheme} from '@emotion/react';
import styled from '@emotion/styled';
import omit from 'lodash/omit';
import LoadingIndicator from 'sentry/components/loadingIndicator';
import {IconChevron, IconClose} from 'sentry/icons';
import {space} from 'sentry/styles/space';
import type {Choices, SelectValue} from 'sentry/types';
import convertFromSelect2Choices from 'sentry/utils/convertFromSelect2Choices';
import PanelProvider from 'sentry/utils/panelProvider';
import type {FormSize} from 'sentry/utils/theme';
import Option from './selectOption';
function isGroupedOptions<OptionType extends OptionTypeBase>(
maybe:
| ReturnType<typeof convertFromSelect2Choices>
| GroupedOptionsType<OptionType>
| OptionType[]
| OptionsType<OptionType>
): maybe is GroupedOptionsType<OptionType> {
if (!maybe || maybe.length === 0) {
return false;
}
return (maybe as GroupedOptionsType<OptionType>)[0].options !== undefined;
}
function ClearIndicator(
props: React.ComponentProps<typeof selectComponents.ClearIndicator>
) {
return (
<selectComponents.ClearIndicator {...props}>
<IconClose legacySize="10px" />
</selectComponents.ClearIndicator>
);
}
function DropdownIndicator(
props: React.ComponentProps<typeof selectComponents.DropdownIndicator>
) {
return (
<selectComponents.DropdownIndicator {...props}>
<IconChevron direction="down" legacySize="14px" />
</selectComponents.DropdownIndicator>
);
}
function MultiValueRemove(
props: React.ComponentProps<typeof selectComponents.MultiValueRemove>
) {
return (
<selectComponents.MultiValueRemove {...props}>
<IconClose legacySize="8px" />
</selectComponents.MultiValueRemove>
);
}
function SelectLoadingIndicator() {
return <LoadingIndicator mini size={20} style={{height: 20, width: 20}} />;
}
function SingleValue(props: React.ComponentProps<typeof selectComponents.SingleValue>) {
const {leadingItems, label} = props.data;
return (
<selectComponents.SingleValue {...props}>
<SingleValueWrap>
{leadingItems}
<SingleValueLabel>{label}</SingleValueLabel>
</SingleValueWrap>
</selectComponents.SingleValue>
);
}
const SingleValueWrap = styled('div')`
display: grid;
grid-auto-flow: column;
gap: ${space(1)};
align-items: center;
`;
const SingleValueLabel = styled('div')`
${p => p.theme.overflowEllipsis};
`;
function Menu(props: React.ComponentProps<typeof selectComponents.Menu>) {
const {children, ...otherProps} = props;
return (
<selectComponents.Menu {...otherProps}>
<PanelProvider>{children}</PanelProvider>
</selectComponents.Menu>
);
}
export interface ControlProps<OptionType extends OptionTypeBase = GeneralSelectValue>
extends Omit<ReactSelectProps<OptionType>, 'onChange' | 'value'> {
/**
* Backwards compatible shim to work with select2 style choice type.
*/
choices?: Choices | ((props: ControlProps<OptionType>) => Choices);
/**
* Set to true to prefix selected values with content
*/
inFieldLabel?: string;
/**
* Whether this selector is being rendered inside a modal. If true, the menu will have a higher z-index.
*/
isInsideModal?: boolean;
/**
* Maximum width of the menu component. Menu item labels that overflow the
* menu's boundaries will automatically be truncated.
*/
maxMenuWidth?: number | string;
/**
* Used by MultiSelectControl.
*/
multiple?: boolean;
/**
* Handler for changes. Narrower than the types in react-select.
*/
onChange?: (value?: OptionType | null) => void;
/**
* Show line dividers between options
*/
showDividers?: boolean;
size?: FormSize;
/**
* Unlike react-select which expects an OptionType as its value
* we accept the option.value and resolve the option object.
* Because this type is embedded in the OptionType generic we
* can't have a good type here.
*/
value?: any;
}
/**
* Additional props provided by forwardRef
*/
interface WrappedControlProps<OptionType extends OptionTypeBase>
extends ControlProps<OptionType> {
/**
* Ref forwarded into ReactSelect component.
* The any is inherited from react-select.
*/
forwardedRef: React.Ref<ReactSelect>;
}
// TODO(ts) The exported component uses forwardRef.
// This means we cannot fill the SelectValue generic
// at the call site. We use `any` here to avoid type errors with select
// controls that have custom option structures
export type GeneralSelectValue = SelectValue<any>;
// We don't care about any options for the styles config
export type StylesConfig = ReactSelectStylesConfig<any, boolean>;
function SelectControl<OptionType extends GeneralSelectValue = GeneralSelectValue>(
props: WrappedControlProps<OptionType>
) {
const theme = useTheme();
const {size, maxMenuWidth, isInsideModal} = props;
// TODO(epurkhiser): The loading indicator should probably also be our loading
// indicator.
// Unfortunately we cannot use emotions `css` helper here, since react-select
// *requires* object styles, which the css helper cannot produce.
const indicatorStyles = useCallback(
(provided: CSSObject): CSSObject => ({
...provided,
padding: '4px',
alignItems: 'center',
cursor: 'pointer',
color: theme.subText,
}),
[theme]
);
const defaultStyles = useMemo<StylesConfig>(
() => ({
control: (_, state: any) => ({
display: 'flex',
color: theme.formText,
background: theme.background,
border: `1px solid ${theme.border}`,
boxShadow: theme.dropShadowMedium,
borderRadius: theme.borderRadius,
transition: 'border 0.1s, box-shadow 0.1s',
alignItems: 'center',
...(state.isFocused && {
borderColor: theme.focusBorder,
boxShadow: `${theme.focusBorder} 0 0 0 1px`,
}),
...(state.isDisabled && {
borderColor: theme.border,
background: theme.backgroundSecondary,
color: theme.disabled,
cursor: 'not-allowed',
}),
...(!state.isSearchable && {
cursor: 'pointer',
}),
...omit(theme.form[size ?? 'md'], 'height'),
}),
menu: provided => ({
...provided,
zIndex: theme.zIndex.dropdown,
background: theme.backgroundElevated,
borderRadius: theme.borderRadius,
boxShadow: `${theme.dropShadowHeavy}, 0 0 0 1px ${theme.translucentBorder}`,
width: 'auto',
minWidth: '100%',
maxWidth: maxMenuWidth ?? 'auto',
}),
menuPortal: provided => ({
...provided,
maxWidth: maxMenuWidth ?? '24rem',
zIndex: isInsideModal ? theme.zIndex.modal + 1 : theme.zIndex.dropdown,
}),
option: provided => ({
...provided,
cursor: 'pointer',
color: theme.textColor,
background: 'transparent',
padding: 0,
':active': {
background: 'transparent',
},
}),
valueContainer: (provided, state) => ({
...provided,
alignItems: 'center',
paddingLeft: theme.formPadding[size ?? 'md'].paddingLeft,
paddingRight: space(0.5),
// offset horizontal margin/padding from multiValue (space(0.25)) &
// multiValueLabel (space(0.75))
...(state.isMulti && {marginLeft: `-${space(1)}`}),
}),
input: provided => ({
...provided,
color: theme.formText,
margin: 0,
}),
singleValue: provided => ({
...provided,
color: theme.formText,
display: 'flex',
alignItems: 'center',
marginLeft: 0,
marginRight: 0,
width: `calc(100% - ${theme.formPadding[size ?? 'md'].paddingLeft}px - ${space(
0.5
)})`,
}),
placeholder: provided => ({
...provided,
color: theme.formPlaceholder,
}),
multiValue: provided => ({
...provided,
color: theme.textColor,
backgroundColor: theme.background,
borderRadius: '2px',
border: `1px solid ${theme.border}`,
display: 'flex',
marginLeft: space(0.25),
}),
multiValueLabel: provided => ({
...provided,
color: theme.textColor,
padding: '0',
paddingLeft: space(0.75),
lineHeight: '1.8',
}),
multiValueRemove: () => ({
cursor: 'pointer',
alignItems: 'center',
borderLeft: `1px solid ${theme.innerBorder}`,
borderRadius: '0 2px 2px 0',
display: 'flex',
padding: '0 4px',
marginLeft: '4px',
'&:hover': {
color: theme.headingColor,
background: theme.backgroundTertiary,
},
}),
indicatorsContainer: () => ({
display: 'grid',
gridAutoFlow: 'column',
gridGap: '2px',
marginRight: '6px',
}),
clearIndicator: indicatorStyles,
dropdownIndicator: indicatorStyles,
loadingIndicator: indicatorStyles,
groupHeading: provided => ({
...provided,
lineHeight: '1.5',
fontWeight: 600,
color: theme.subText,
marginBottom: 0,
padding: `${space(0.5)} ${space(1.5)}`,
':empty': {
display: 'none',
},
}),
group: provided => ({
...provided,
paddingTop: 0,
':last-of-type': {
paddingBottom: 0,
},
':not(:last-of-type)': {
position: 'relative',
marginBottom: space(1),
},
// Add divider between sections
':not(:last-of-type)::after': {
content: '""',
position: 'absolute',
left: space(1.5),
right: space(1.5),
bottom: 0,
borderBottom: `solid 1px ${theme.innerBorder}`,
},
}),
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[theme, size, maxMenuWidth, indicatorStyles]
);
const getFieldLabelStyle = (label?: string): CSSObject => ({
':before': {
content: `"${label}"`,
color: theme.gray300,
fontWeight: 600,
marginRight: space(1),
},
});
const {
async,
creatable,
options,
choices,
clearable,
components,
styles,
value,
inFieldLabel,
...rest
} = props;
// Compatibility with old select2 API
//
// TODO(epurkhiser): We need better types on this coomponent to have higher
// confidence to remove this. There's likely few places we still use the
// select2 style choices.
const choicesOrOptions =
convertFromSelect2Choices(typeof choices === 'function' ? choices(props) : choices) ||
options;
// It's possible that `choicesOrOptions` does not exist (e.g. in the case of AsyncSelect)
let mappedValue = value;
if (choicesOrOptions) {
/**
* Value is expected to be object like the options list, we map it back from the options list.
* Note that if the component doesn't have options or choices passed in
* because the select component fetches the options finding the mappedValue will fail
* and the component won't work
*/
let flatOptions: any[] = [];
if (isGroupedOptions<OptionType>(choicesOrOptions)) {
flatOptions = choicesOrOptions.flatMap(option => option.options);
} else {
flatOptions = choicesOrOptions.flatMap(option => option);
}
mappedValue =
props.multiple && Array.isArray(value)
? value.map(val => flatOptions.find(option => option.value === val))
: flatOptions.find(opt => opt.value === value) || value;
}
// Override the default style with in-field labels if they are provided
const inFieldLabelStyles: StylesConfig = {
singleValue: (base: CSSObject) => ({
...base,
...getFieldLabelStyle(inFieldLabel),
}),
placeholder: (base: CSSObject) => ({
...base,
...getFieldLabelStyle(inFieldLabel),
}),
};
const labelOrDefaultStyles: StylesConfig = inFieldLabel
? mergeStyles(defaultStyles, inFieldLabelStyles)
: defaultStyles;
// Allow the provided `styles` prop to override default styles using the same
// function interface provided by react-styled. This ensures the `provided`
// styles include our overridden default styles
const mappedStyles: StylesConfig = styles
? mergeStyles(labelOrDefaultStyles, styles)
: labelOrDefaultStyles;
const replacedComponents = {
SingleValue,
ClearIndicator,
DropdownIndicator,
MultiValueRemove,
LoadingIndicator: SelectLoadingIndicator,
IndicatorSeparator: null,
Menu,
Option,
...components,
};
const filterOptions = createFilter({
// Use `textValue` if available
stringify: option => option.data.textValue ?? `${option.label} ${option.value}`,
});
return (
<SelectPicker<OptionType>
filterOption={filterOptions}
styles={mappedStyles}
components={replacedComponents}
async={async}
creatable={creatable}
isClearable={clearable}
backspaceRemovesValue={clearable}
value={mappedValue}
isMulti={props.multiple || props.multi}
isDisabled={props.isDisabled || props.disabled}
isOptionDisabled={opt => !!opt.disabled}
showDividers={props.showDividers}
options={options || (choicesOrOptions as OptionsType<OptionType>)}
openMenuOnFocus={props.openMenuOnFocus}
blurInputOnSelect={!props.multiple && !props.multi}
closeMenuOnSelect={!(props.multiple || props.multi)}
hideSelectedOptions={false}
tabSelectsValue={false}
{...rest}
/>
);
}
export interface PickerProps<OptionType extends OptionTypeBase>
extends ControlProps<OptionType> {
/**
* Enable async option loading.
*/
async?: boolean;
/**
* Enable 'clearable' which allows values to be removed.
*/
clearable?: boolean;
/**
* Enable 'create' mode which allows values to be created inline.
*/
creatable?: boolean;
}
function SelectPicker<OptionType extends OptionTypeBase>({
async,
creatable,
forwardedRef,
...props
}: PickerProps<OptionType>) {
// Pick the right component to use
// Using any here as react-select types also use any
let Component: React.ComponentType<any> | undefined;
if (async && creatable) {
Component = AsyncCreatable;
} else if (async && !creatable) {
Component = Async;
} else if (creatable) {
Component = Creatable;
} else {
Component = ReactSelect;
}
return <Component ref={forwardedRef} {...props} />;
}
// The generics need to be filled here as forwardRef can't expose generics.
const RefForwardedSelectControl = forwardRef<
ReactSelect<GeneralSelectValue>,
ControlProps
>(function RefForwardedSelectControl(props, ref) {
return <SelectControl forwardedRef={ref as any} {...props} />;
});
export default RefForwardedSelectControl;