diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 04ff7681fba..366f2b11056 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -29,6 +29,7 @@ "@grafana/slate-react": "0.22.9-grafana", "@torkelo/react-select": "2.1.1", "@types/react-color": "2.17.0", + "@types/react-select": "2.0.15", "@types/slate": "0.47.1", "@types/slate-react": "0.22.5", "bizcharts": "^3.5.5", diff --git a/packages/grafana-ui/src/components/Button/Button.story.tsx b/packages/grafana-ui/src/components/Button/Button.story.tsx index b73a6419aec..149dd284499 100644 --- a/packages/grafana-ui/src/components/Button/Button.story.tsx +++ b/packages/grafana-ui/src/components/Button/Button.story.tsx @@ -4,13 +4,14 @@ import { Button, LinkButton } from './Button'; import withPropsCombinations from 'react-storybook-addon-props-combinations'; import { action } from '@storybook/addon-actions'; import { ThemeableCombinationsRowRenderer } from '../../utils/storybook/CombinationsRowRenderer'; -import { select, boolean } from '@storybook/addon-knobs'; +import { boolean } from '@storybook/addon-knobs'; +import { getIconKnob } from '../../utils/storybook/knobs'; const ButtonStories = storiesOf('UI/Button', module); const defaultProps = { onClick: [action('Button clicked')], - children: ['Click, click!'], + children: ['Click click!'], }; const variants = { @@ -35,15 +36,10 @@ ButtonStories.add('as button element', () => renderButtonStory(Button)); ButtonStories.add('as link element', () => renderButtonStory(LinkButton)); ButtonStories.add('with icon', () => { - const iconKnob = select( - 'Icon', - { - Plus: 'fa fa-plus', - User: 'fa fa-user', - Gear: 'fa fa-gear', - Annotation: 'gicon gicon-annotation', - }, - 'fa fa-plus' - ); - return withPropsCombinations(Button, { ...variants, ...defaultProps, icon: [iconKnob] }, combinationOptions)(); + const icon = getIconKnob(); + return withPropsCombinations( + Button, + { ...variants, ...defaultProps, icon: [icon && `fa fa-${icon}`] }, + combinationOptions + )(); }); diff --git a/packages/grafana-ui/src/components/Button/Button.tsx b/packages/grafana-ui/src/components/Button/Button.tsx index 378b87a9744..664d1955aa2 100644 --- a/packages/grafana-ui/src/components/Button/Button.tsx +++ b/packages/grafana-ui/src/components/Button/Button.tsx @@ -18,12 +18,13 @@ type CommonProps = { }; type ButtonProps = CommonProps & ButtonHTMLAttributes; -export const Button: React.FunctionComponent = props => { +export const Button = React.forwardRef((props, ref) => { const theme = useContext(ThemeContext); const { size, variant, icon, children, className, styles: stylesProp, ...buttonProps } = props; // Default this to 'button', otherwise html defaults to 'submit' which then submits any form it is in. buttonProps.type = buttonProps.type || 'button'; + const styles: ButtonStyles = stylesProp || getButtonStyles({ @@ -32,14 +33,14 @@ export const Button: React.FunctionComponent = props => { variant: variant || 'primary', }); + const buttonClassName = cx(styles.button, icon && styles.buttonWithIcon, icon && !children && styles.iconButton); return ( - ); -}; +}); + Button.displayName = 'Button'; type LinkButtonProps = CommonProps & @@ -48,7 +49,8 @@ type LinkButtonProps = CommonProps & // disabled. disabled?: boolean; }; -export const LinkButton: React.FunctionComponent = props => { + +export const LinkButton = React.forwardRef((props, ref) => { const theme = useContext(ThemeContext); const { size, variant, icon, children, className, styles: stylesProp, ...anchorProps } = props; const styles: ButtonStyles = @@ -59,12 +61,11 @@ export const LinkButton: React.FunctionComponent = props => { variant: variant || 'primary', }); + const buttonClassName = cx(styles.button, icon && styles.buttonWithIcon, icon && !children && styles.iconButton); return ( - - - {children} - + + {children} ); -}; +}); LinkButton.displayName = 'LinkButton'; diff --git a/packages/grafana-ui/src/components/Button/ButtonContent.tsx b/packages/grafana-ui/src/components/Button/ButtonContent.tsx index 70ea9d6bf5b..28b77dcbfaa 100644 --- a/packages/grafana-ui/src/components/Button/ButtonContent.tsx +++ b/packages/grafana-ui/src/components/Button/ButtonContent.tsx @@ -1,39 +1,44 @@ import React from 'react'; -import cx from 'classnames'; import { css } from 'emotion'; -import { stylesFactory } from '../../themes'; +import { stylesFactory, useTheme } from '../../themes'; +import { GrafanaTheme } from '@grafana/data'; -const getStyles = stylesFactory(() => ({ +const getStyles = stylesFactory((theme: GrafanaTheme) => ({ content: css` + display: flex; + flex-direction: row; + align-items: center; white-space: nowrap; + height: 100%; + `, + + icon: css` + & + * { + margin-left: ${theme.spacing.sm}; + } `, })); type Props = { icon?: string; className?: string; - iconClassName?: string; children: React.ReactNode; }; export function ButtonContent(props: Props) { - const { icon, className, iconClassName, children } = props; - const styles = getStyles(); - if (icon && children) { - return ( - - -     - {children} - - ); - } - if (icon) { - return ( - - - - ); - } + const { icon, children } = props; + const theme = useTheme(); + const styles = getStyles(theme); - return {children}; + const iconElement = icon && ( + + + + ); + + return ( + + {iconElement} + {children} + + ); } diff --git a/packages/grafana-ui/src/components/Button/__snapshots__/Button.test.tsx.snap b/packages/grafana-ui/src/components/Button/__snapshots__/Button.test.tsx.snap index 3db226f1e92..3abfa65f8b6 100644 --- a/packages/grafana-ui/src/components/Button/__snapshots__/Button.test.tsx.snap +++ b/packages/grafana-ui/src/components/Button/__snapshots__/Button.test.tsx.snap @@ -1,5 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Button renders correct html 1`] = `""`; +exports[`Button renders correct html 1`] = `""`; -exports[`LinkButton renders correct html 1`] = `"   Click me"`; +exports[`LinkButton renders correct html 1`] = `"Click me"`; diff --git a/packages/grafana-ui/src/components/Button/styles.ts b/packages/grafana-ui/src/components/Button/styles.ts index bc949513fa2..cbef4e28eea 100644 --- a/packages/grafana-ui/src/components/Button/styles.ts +++ b/packages/grafana-ui/src/components/Button/styles.ts @@ -120,10 +120,18 @@ export const getButtonStyles = stylesFactory(({ theme, size, variant }: StyleDep box-shadow: none; } `, + buttonWithIcon: css` + padding-left: ${theme.spacing.sm}; + `, + // used for buttons with icon onlys + iconButton: css` + padding-right: 0; + `, iconWrap: css` label: button-icon-wrap; - display: flex; - align-items: center; + & + * { + margin-left: ${theme.spacing.sm}; + } `, }; }); diff --git a/packages/grafana-ui/src/components/Button/types.ts b/packages/grafana-ui/src/components/Button/types.ts index a163a06e8ee..eb67b3c0420 100644 --- a/packages/grafana-ui/src/components/Button/types.ts +++ b/packages/grafana-ui/src/components/Button/types.ts @@ -12,6 +12,8 @@ export interface StyleDeps { export interface ButtonStyles { button: string; + buttonWithIcon: string; + iconButton: string; iconWrap: string; icon?: string; } diff --git a/packages/grafana-ui/src/components/Forms/Button.story.tsx b/packages/grafana-ui/src/components/Forms/Button.story.tsx index 42416d085e2..583bb08f55b 100644 --- a/packages/grafana-ui/src/components/Forms/Button.story.tsx +++ b/packages/grafana-ui/src/components/Forms/Button.story.tsx @@ -1,7 +1,8 @@ import React from 'react'; +import { select, text } from '@storybook/addon-knobs'; import { Button, ButtonVariant } from './Button'; import { withCenteredStory, withHorizontallyCenteredStory } from '../../utils/storybook/withCenteredStory'; -import { select, text } from '@storybook/addon-knobs'; +import { getIconKnob } from '../../utils/storybook/knobs'; import { ButtonSize } from '../Button/types'; import mdx from './Button.mdx'; @@ -24,9 +25,10 @@ export const simple = () => { const variant = select('Variant', variants, 'primary'); const size = select('Size', sizes, 'md'); const buttonText = text('text', 'Button'); + const icon = getIconKnob(); return ( - ); diff --git a/packages/grafana-ui/src/components/Forms/Button.tsx b/packages/grafana-ui/src/components/Forms/Button.tsx index 084c764ee72..df6f7a0f5b5 100644 --- a/packages/grafana-ui/src/components/Forms/Button.tsx +++ b/packages/grafana-ui/src/components/Forms/Button.tsx @@ -76,6 +76,7 @@ const getPropertiesForVariant = (theme: GrafanaTheme, variant: ButtonVariant) => // Need to do this because of mismatch between variants in standard buttons and here type StyleProps = Omit & { variant: ButtonVariant }; + export const getButtonStyles = stylesFactory(({ theme, size, variant }: StyleProps) => { const { padding, fontSize, height } = getPropertiesForButtonSize(theme, size); const { background, borderColor, variantStyles } = getPropertiesForVariant(theme, variant); @@ -83,7 +84,6 @@ export const getButtonStyles = stylesFactory(({ theme, size, variant }: StylePro return { button: cx( css` - position: relative; label: button; display: inline-flex; align-items: center; @@ -111,10 +111,18 @@ export const getButtonStyles = stylesFactory(({ theme, size, variant }: StylePro ${variantStyles} ` ), + buttonWithIcon: css` + padding-left: ${theme.spacing.sm}; + `, + // used for buttons with icon only + iconButton: css` + padding-right: 0; + `, iconWrap: css` label: button-icon-wrap; - display: flex; - align-items: center; + & + * { + margin-left: ${theme.spacing.sm}; + } `, }; }); @@ -130,25 +138,25 @@ type CommonProps = { className?: string; }; -type ButtonProps = CommonProps & ButtonHTMLAttributes; +export type ButtonProps = CommonProps & ButtonHTMLAttributes; -export const Button = (props: ButtonProps) => { +export const Button = React.forwardRef((props, ref) => { const theme = useContext(ThemeContext); const styles = getButtonStyles({ theme, size: props.size || 'md', variant: props.variant || 'primary', }); - return ; -}; + return ; +}); type ButtonLinkProps = CommonProps & AnchorHTMLAttributes; -export const LinkButton = (props: ButtonLinkProps) => { +export const LinkButton = React.forwardRef((props, ref) => { const theme = useContext(ThemeContext); const styles = getButtonStyles({ theme, size: props.size || 'md', variant: props.variant || 'primary', }); - return ; -}; + return ; +}); diff --git a/packages/grafana-ui/src/components/Forms/Input/Input.story.tsx b/packages/grafana-ui/src/components/Forms/Input/Input.story.tsx index 26f9a0ab590..49e141f5653 100644 --- a/packages/grafana-ui/src/components/Forms/Input/Input.story.tsx +++ b/packages/grafana-ui/src/components/Forms/Input/Input.story.tsx @@ -55,10 +55,15 @@ export const simple = () => { const addonAfter = ; const addonBefore =
Input
; const prefix = select('Prefix', prefixSuffixOpts, null, VISUAL_GROUP); + const suffix = select('Suffix', prefixSuffixOpts, null, VISUAL_GROUP); let prefixEl: any = prefix; if (prefix && prefix.match(/icon-/g)) { prefixEl = ; } + let suffixEl: any = suffix; + if (suffix && suffix.match(/icon-/g)) { + suffixEl = ; + } const CONTAINER_GROUP = 'Container options'; // --- @@ -80,6 +85,7 @@ export const simple = () => { disabled={disabled} invalid={invalid} prefix={prefixEl} + suffix={suffixEl} loading={loading} addonBefore={before && addonBefore} addonAfter={after && addonAfter} diff --git a/packages/grafana-ui/src/components/Forms/Input/Input.tsx b/packages/grafana-ui/src/components/Forms/Input/Input.tsx index 9b40a043a6d..6741ecdc273 100644 --- a/packages/grafana-ui/src/components/Forms/Input/Input.tsx +++ b/packages/grafana-ui/src/components/Forms/Input/Input.tsx @@ -1,4 +1,4 @@ -import React, { FC, HTMLProps, ReactNode } from 'react'; +import React, { HTMLProps, ReactNode } from 'react'; import { GrafanaTheme } from '@grafana/data'; import { css, cx } from 'emotion'; import { getFocusStyle, inputSizes, sharedInputStyle } from '../commonStyles'; @@ -12,6 +12,8 @@ export interface Props extends Omit, 'prefix' | 'siz invalid?: boolean; /** Show an icon as a prefix in the input */ prefix?: JSX.Element | string | null; + /** Show an icon as a suffix in the input */ + suffix?: JSX.Element | string | null; /** Show a loading indicator as a suffix in the input */ loading?: boolean; /** Add a component as an addon before the input */ @@ -110,6 +112,9 @@ export const getInputStyles = stylesFactory(({ theme, invalid = false }: StyleDe &:not(:last-child) { padding-right: ${prefixSuffixStaticWidth}; } + &[readonly] { + cursor: default; + } } `, @@ -124,8 +129,6 @@ export const getInputStyles = stylesFactory(({ theme, invalid = false }: StyleDe border-radius: ${borderRadius}; height: 100%; width: 100%; - padding: 0 ${theme.spacing.sm} 0 ${theme.spacing.sm}; - font-size: ${theme.typography.size.md}; /* Restoring increase/decrease spinner on number inputs. Overwriting rules implemented in @@ -143,6 +146,10 @@ export const getInputStyles = stylesFactory(({ theme, invalid = false }: StyleDe } ` ), + inputDisabled: css` + background-color: ${colors.formInputBgDisabled}; + color: ${colors.formInputDisabledText}; + `, addon: css` label: input-addon; display: flex; @@ -184,7 +191,6 @@ export const getInputStyles = stylesFactory(({ theme, invalid = false }: StyleDe border-right: none; border-top-right-radius: 0; border-bottom-right-radius: 0; - left: 0; ` ), suffix: cx( @@ -199,11 +205,16 @@ export const getInputStyles = stylesFactory(({ theme, invalid = false }: StyleDe right: 0; ` ), + loadingIndicator: css` + & + * { + margin-left: ${theme.spacing.xs}; + } + `, }; }); -export const Input: FC = props => { - const { addonAfter, addonBefore, prefix, invalid, loading, size = 'auto', ...restProps } = props; +export const Input = React.forwardRef((props, ref) => { + const { addonAfter, addonBefore, prefix, suffix, invalid, loading, size = 'auto', ...restProps } = props; /** * Prefix & suffix are positioned absolutely within inputWrapper. We use client rects below to apply correct padding to the input * when prefix/suffix is larger than default (28px = 16px(icon) + 12px(left/right paddings)). @@ -227,6 +238,7 @@ export const Input: FC = props => { )} = props => { }} /> - {loading && ( + {(suffix || loading) && (
- + {loading && } + {suffix}
)} @@ -245,4 +258,4 @@ export const Input: FC = props => { {!!addonAfter &&
{addonAfter}
} ); -}; +}); diff --git a/packages/grafana-ui/src/components/Forms/Select/ButtonSelect.tsx b/packages/grafana-ui/src/components/Forms/Select/ButtonSelect.tsx new file mode 100644 index 00000000000..91ef02ca544 --- /dev/null +++ b/packages/grafana-ui/src/components/Forms/Select/ButtonSelect.tsx @@ -0,0 +1,85 @@ +import React from 'react'; + +import { Button, ButtonVariant, ButtonProps } from '../Button'; +import { ButtonSize } from '../../Button/types'; +import { SelectCommonProps, SelectBase } from './SelectBase'; +import { css } from 'emotion'; +import { useTheme } from '../../../themes'; +import { Icon } from '../../Icon/Icon'; +import { IconType } from '../../Icon/types'; + +interface ButtonSelectProps extends Omit, 'renderControl' | 'size' | 'prefix'> { + icon?: IconType; + variant?: ButtonVariant; + size?: ButtonSize; +} + +interface SelectButtonProps extends Omit { + icon?: IconType; + isOpen?: boolean; +} + +const SelectButton: React.FC = ({ icon, children, isOpen, ...buttonProps }) => { + const theme = useTheme(); + const styles = { + wrapper: css` + display: flex; + align-items: center; + justify-content: space-between; + max-width: 200px; + text-overflow: ellipsis; + `, + iconWrap: css` + padding: 0 15px 0 0; + `, + caretWrap: css` + padding-left: ${theme.spacing.sm}; + margin-left: ${theme.spacing.sm}; + margin-right: -${theme.spacing.sm}; + height: 100%; + `, + }; + const buttonIcon = `fa fa-${icon}`; + const caretIcon = isOpen ? 'caret-up' : 'caret-down'; + return ( + + ); +}; + +export function ButtonSelect({ + placeholder, + icon, + variant = 'primary', + size = 'md', + className, + disabled, + ...selectProps +}: ButtonSelectProps) { + const buttonProps = { + icon, + variant, + size, + className, + disabled, + }; + + return ( + { + return ( + + {value ? value.label : placeholder} + + ); + }} + /> + ); +} diff --git a/packages/grafana-ui/src/components/Forms/Select/DropdownIndicator.tsx b/packages/grafana-ui/src/components/Forms/Select/DropdownIndicator.tsx new file mode 100644 index 00000000000..b3e8f34ac3a --- /dev/null +++ b/packages/grafana-ui/src/components/Forms/Select/DropdownIndicator.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { Icon } from '../../Icon/Icon'; + +interface DropdownIndicatorProps { + isOpen: boolean; +} + +export const DropdownIndicator: React.FC = ({ isOpen }) => { + const icon = isOpen ? 'caret-up' : 'caret-down'; + return ; +}; diff --git a/packages/grafana-ui/src/components/Forms/Select/IndicatorsContainer.tsx b/packages/grafana-ui/src/components/Forms/Select/IndicatorsContainer.tsx new file mode 100644 index 00000000000..38db9ac9984 --- /dev/null +++ b/packages/grafana-ui/src/components/Forms/Select/IndicatorsContainer.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { useTheme } from '../../../themes/ThemeContext'; +import { getInputStyles } from '../Input/Input'; +import { cx, css } from 'emotion'; + +export const IndicatorsContainer = React.forwardRef>((props, ref) => { + const { children } = props; + const theme = useTheme(); + const styles = getInputStyles({ theme, invalid: false }); + + return ( +
+ {children} +
+ ); +}); diff --git a/packages/grafana-ui/src/components/Forms/Select/InputControl.tsx b/packages/grafana-ui/src/components/Forms/Select/InputControl.tsx new file mode 100644 index 00000000000..db72e590579 --- /dev/null +++ b/packages/grafana-ui/src/components/Forms/Select/InputControl.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { useTheme } from '../../../themes/ThemeContext'; +import { getFocusCss, sharedInputStyle } from '../commonStyles'; +import { getInputStyles } from '../Input/Input'; +import { cx, css } from 'emotion'; +import { stylesFactory } from '../../../themes'; +import { GrafanaTheme } from '@grafana/data'; + +interface InputControlProps { + /** Show an icon as a prefix in the input */ + prefix?: JSX.Element | string | null; + focused: boolean; + invalid: boolean; + disabled: boolean; + innerProps: any; +} + +const getInputControlStyles = stylesFactory( + (theme: GrafanaTheme, invalid: boolean, focused: boolean, disabled: boolean, withPrefix: boolean) => { + const styles = getInputStyles({ theme, invalid }); + + return { + wrapper: cx( + styles.wrapper, + sharedInputStyle(theme, invalid), + focused && + css` + ${getFocusCss(theme)} + `, + disabled && styles.inputDisabled, + css` + min-height: 32px; + height: auto; + flex-direction: row; + padding-right: 0; + max-width: 100%; + align-items: center; + cursor: default; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + position: relative; + box-sizing: border-box; + `, + withPrefix && + css` + padding-left: 0; + ` + ), + prefix: cx( + styles.prefix, + css` + position: relative; + ` + ), + }; + } +); + +export const InputControl = React.forwardRef>( + function InputControl({ focused, invalid, disabled, children, innerProps, prefix, ...otherProps }, ref) { + const theme = useTheme(); + const styles = getInputControlStyles(theme, invalid, focused, disabled, !!prefix); + return ( +
+ {prefix &&
{prefix}
} + {children} +
+ ); + } +); diff --git a/packages/grafana-ui/src/components/Forms/Select/MultiValue.tsx b/packages/grafana-ui/src/components/Forms/Select/MultiValue.tsx new file mode 100644 index 00000000000..126551ca546 --- /dev/null +++ b/packages/grafana-ui/src/components/Forms/Select/MultiValue.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { useTheme } from '../../../themes'; +import { getSelectStyles } from './getSelectStyles'; +import { Icon } from '../../Icon/Icon'; + +interface MultiValueContainerProps { + innerProps: any; +} + +export const MultiValueContainer: React.FC = ({ innerProps, children }) => { + const theme = useTheme(); + const styles = getSelectStyles(theme); + + return ( +
+ {children} +
+ ); +}; + +export type MultiValueRemoveProps = { + innerProps: any; +}; + +export const MultiValueRemove: React.FC = ({ children, innerProps }) => { + const theme = useTheme(); + const styles = getSelectStyles(theme); + return ( +
+ +
+ ); +}; diff --git a/packages/grafana-ui/src/components/Forms/Select/Select.story.tsx b/packages/grafana-ui/src/components/Forms/Select/Select.story.tsx new file mode 100644 index 00000000000..14be3595470 --- /dev/null +++ b/packages/grafana-ui/src/components/Forms/Select/Select.story.tsx @@ -0,0 +1,265 @@ +import React, { useState } from 'react'; +import { Select, AsyncSelect, MultiSelect, AsyncMultiSelect } from './Select'; +import { withCenteredStory, withHorizontallyCenteredStory } from '../../../utils/storybook/withCenteredStory'; +import { SelectableValue } from '@grafana/data'; +import { getAvailableIcons, IconType } from '../../Icon/types'; +import { select, boolean } from '@storybook/addon-knobs'; +import { Icon } from '../../Icon/Icon'; +import { Button } from '../Button'; +import { ButtonSelect } from './ButtonSelect'; +import { getIconKnob } from '../../../utils/storybook/knobs'; +import kebabCase from 'lodash/kebabCase'; + +export default { + title: 'UI/Forms/Select', + component: Select, + decorators: [withCenteredStory, withHorizontallyCenteredStory], +}; + +const generateOptions = () => { + const values = [ + 'Sharilyn Markowitz', + 'Naomi Striplin', + 'Beau Bevel', + 'Garrett Starkes', + 'Hildegarde Pedro', + 'Gudrun Seyler', + 'Eboni Raines', + 'Hye Felix', + 'Chau Brito', + 'Heidy Zook', + 'Karima Husain', + 'Virgil Mckinny', + 'Kaley Dodrill', + 'Sharan Ruf', + 'Edgar Loveland', + 'Judie Sanger', + 'Season Bundrick', + 'Ok Vicente', + 'Garry Spitz', + 'Han Harnish', + ]; + + return values.map>(name => ({ + value: kebabCase(name), + label: name, + })); +}; + +const loadAsyncOptions = () => { + return new Promise>>(resolve => { + setTimeout(() => { + resolve(generateOptions()); + }, 2000); + }); +}; + +const getKnobs = () => { + const BEHAVIOUR_GROUP = 'Behaviour props'; + const disabled = boolean('Disabled', false, BEHAVIOUR_GROUP); + const invalid = boolean('Invalid', false, BEHAVIOUR_GROUP); + const loading = boolean('Loading', false, BEHAVIOUR_GROUP); + const prefixSuffixOpts = { + None: null, + Text: '$', + ...getAvailableIcons().reduce>((prev, c) => { + return { + ...prev, + [`Icon: ${c}`]: `icon-${c}`, + }; + }, {}), + }; + const VISUAL_GROUP = 'Visual options'; + // --- + const prefix = select('Prefix', prefixSuffixOpts, null, VISUAL_GROUP); + + let prefixEl: any = prefix; + if (prefix && prefix.match(/icon-/g)) { + prefixEl = ; + } + + return { + disabled, + invalid, + loading, + prefixEl, + }; +}; + +const getDynamicProps = () => { + const knobs = getKnobs(); + return { + disabled: knobs.disabled, + isLoading: knobs.loading, + invalid: knobs.invalid, + prefix: knobs.prefixEl, + }; +}; + +export const basic = () => { + const [value, setValue] = useState>(); + + return ( + <> + { + setValue(v.value); + }} + size="md" + {...getDynamicProps()} + /> + + ); +}; + +/** + * Uses plain values instead of SelectableValue + */ +export const multiPlainValue = () => { + const [value, setValue] = useState(); + + return ( + <> + { + setValue(v.map((v: any) => v.value)); + }} + size="md" + {...getDynamicProps()} + /> + + ); +}; + +export const multiSelect = () => { + const [value, setValue] = useState>>([]); + + return ( + <> + { + setValue(v); + }} + size="md" + {...getDynamicProps()} + /> + + ); +}; + +export const multiSelectAsync = () => { + const [value, setValue] = useState>>(); + + return ( + { + setValue(v); + }} + size="md" + allowCustomValue + {...getDynamicProps()} + /> + ); +}; +export const buttonSelect = () => { + const [value, setValue] = useState>(); + const icon = getIconKnob(); + return ( + { + setValue(v); + }} + size="md" + allowCustomValue + icon={icon} + {...getDynamicProps()} + /> + ); +}; + +export const basicSelectAsync = () => { + const [value, setValue] = useState>(); + + return ( + { + setValue(v); + }} + size="md" + {...getDynamicProps()} + /> + ); +}; + +export const customizedControl = () => { + const [value, setValue] = useState>(); + + return ( + { + setValue(v); + }} + size="md" + allowCustomValue + {...getDynamicProps()} + /> + + ); +}; diff --git a/packages/grafana-ui/src/components/Forms/Select/Select.tsx b/packages/grafana-ui/src/components/Forms/Select/Select.tsx new file mode 100644 index 00000000000..06a25b794da --- /dev/null +++ b/packages/grafana-ui/src/components/Forms/Select/Select.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { SelectableValue } from '@grafana/data'; +import { SelectCommonProps, SelectBase, MultiSelectCommonProps, SelectAsyncProps } from './SelectBase'; + +export function Select(props: SelectCommonProps) { + return ; +} + +export function MultiSelect(props: MultiSelectCommonProps) { + // @ts-ignore + return ; +} + +interface AsyncSelectProps extends Omit, 'options'>, SelectAsyncProps { + // AsyncSelect has options stored internally. We cannot enable plain values as we don't have access to the fetched options + value?: SelectableValue; +} + +export function AsyncSelect(props: AsyncSelectProps) { + return ; +} + +interface AsyncMultiSelectProps extends Omit, 'options'>, SelectAsyncProps { + // AsyncSelect has options stored internally. We cannot enable plain values as we don't have access to the fetched options + value?: Array>; +} + +export function AsyncMultiSelect(props: AsyncMultiSelectProps) { + // @ts-ignore + return ; +} diff --git a/packages/grafana-ui/src/components/Forms/Select/SelectBase.test.tsx b/packages/grafana-ui/src/components/Forms/Select/SelectBase.test.tsx new file mode 100644 index 00000000000..99a8c550950 --- /dev/null +++ b/packages/grafana-ui/src/components/Forms/Select/SelectBase.test.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { SelectBase } from './SelectBase'; +import { SelectableValue } from '@grafana/data'; + +const onChangeHandler = () => jest.fn(); +const findMenuElement = (container: ReactWrapper) => container.find({ 'aria-label': 'Select options menu' }); +const options: Array> = [ + { + label: 'Option 1', + value: 1, + }, + { + label: 'Option 2', + value: 2, + }, +]; + +describe('SelectBase', () => { + it('renders without error', () => { + mount(); + }); + + it('renders empty options information', () => { + const container = mount(); + const noopt = container.find({ 'aria-label': 'No options provided' }); + expect(noopt).toHaveLength(1); + }); + + describe('when openMenuOnFocus prop', () => { + describe('is provided', () => { + it('opens on focus', () => { + const container = mount(); + container.find('input').simulate('focus'); + + const menu = findMenuElement(container); + expect(menu).toHaveLength(1); + }); + }); + describe('is not provided', () => { + it.each` + key + ${'ArrowDown'} + ${'ArrowUp'} + ${' '} + `('opens on arrow down/up or space', ({ key }) => { + const container = mount(); + const input = container.find('input'); + input.simulate('focus'); + input.simulate('keydown', { key }); + const menu = findMenuElement(container); + expect(menu).toHaveLength(1); + }); + }); + }); + + describe('options', () => { + it('renders menu with provided options', () => { + const container = mount(); + const menuOptions = container.find({ 'aria-label': 'Select option' }); + expect(menuOptions).toHaveLength(2); + }); + it('call onChange handler when option is selected', () => { + const spy = jest.fn(); + const handler = (value: SelectableValue) => spy(value); + const container = mount(); + const menuOptions = container.find({ 'aria-label': 'Select option' }); + expect(menuOptions).toHaveLength(2); + const menuOption = menuOptions.first(); + menuOption.simulate('click'); + + expect(spy).toBeCalledWith({ + label: 'Option 1', + value: 1, + }); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/Forms/Select/SelectBase.tsx b/packages/grafana-ui/src/components/Forms/Select/SelectBase.tsx new file mode 100644 index 00000000000..f6bac5f9bd0 --- /dev/null +++ b/packages/grafana-ui/src/components/Forms/Select/SelectBase.tsx @@ -0,0 +1,315 @@ +import React from 'react'; +import { SelectableValue, deprecationWarning } from '@grafana/data'; +// @ts-ignore +import { default as ReactSelect, Creatable } from '@torkelo/react-select'; +// @ts-ignore +import { default as ReactAsyncSelect } from '@torkelo/react-select/lib/Async'; + +import { Icon } from '../../Icon/Icon'; +import { css } from 'emotion'; +import { inputSizes } from '../commonStyles'; +import { FormInputSize } from '../types'; +import resetSelectStyles from './resetSelectStyles'; +import { SelectMenu, SelectMenuOptions } from './SelectMenu'; +import { IndicatorsContainer } from './IndicatorsContainer'; +import { ValueContainer } from './ValueContainer'; +import { InputControl } from './InputControl'; +import { DropdownIndicator } from './DropdownIndicator'; +import { SelectOptionGroup } from './SelectOptionGroup'; +import { SingleValue } from './SingleValue'; +import { MultiValueContainer, MultiValueRemove } from './MultiValue'; +import { useTheme } from '../../../themes'; +import { getSelectStyles } from './getSelectStyles'; + +type SelectValue = T | SelectableValue | T[] | Array>; + +export interface SelectCommonProps { + className?: string; + options?: Array>; + defaultValue?: any; + value?: SelectValue; + getOptionLabel?: (item: SelectableValue) => string; + getOptionValue?: (item: SelectableValue) => string; + onChange: (value: SelectableValue) => {} | void; + placeholder?: string; + disabled?: boolean; + isSearchable?: boolean; + isClearable?: boolean; + autoFocus?: boolean; + openMenuOnFocus?: boolean; + onBlur?: () => void; + maxMenuHeight?: number; + isLoading?: boolean; + noOptionsMessage?: string; + isMulti?: boolean; + backspaceRemovesValue?: boolean; + isOpen?: boolean; + components?: any; + onOpenMenu?: () => void; + onCloseMenu?: () => void; + tabSelectsValue?: boolean; + formatCreateLabel?: (input: string) => string; + allowCustomValue?: boolean; + width?: number; + size?: FormInputSize; + /** item to be rendered in front of the input */ + prefix?: JSX.Element | string | null; + renderControl?: ControlComponent; +} + +export interface SelectAsyncProps { + /** When specified as boolean the loadOptions will execute when component is mounted */ + defaultOptions?: boolean | Array>; + /** Asynchroniously load select options */ + loadOptions?: (query: string) => Promise>>; + /** Message to display when options are loading */ + loadingMessage?: string; +} + +export interface MultiSelectCommonProps extends Omit, 'onChange' | 'isMulti' | 'value'> { + value?: Array> | T[]; + onChange: (item: Array>) => {} | void; +} + +export interface SelectBaseProps extends SelectCommonProps, SelectAsyncProps { + invalid?: boolean; +} + +export interface CustomControlProps { + ref: React.Ref; + isOpen: boolean; + /** Currently selected value */ + value?: SelectableValue; + /** onClick will be automatically passed to custom control allowing menu toggle */ + onClick: () => void; + /** onBlur will be automatically passed to custom control closing the menu on element blur */ + onBlur: () => void; + disabled: boolean; + invalid: boolean; +} + +export type ControlComponent = React.ComponentType>; + +const CustomControl = (props: any) => { + const { + children, + innerProps, + selectProps: { menuIsOpen, onMenuClose, onMenuOpen }, + isFocused, + isMulti, + getValue, + innerRef, + } = props; + const selectProps = props.selectProps as SelectBaseProps; + + if (selectProps.renderControl) { + return React.createElement(selectProps.renderControl, { + isOpen: menuIsOpen, + value: isMulti ? getValue() : getValue()[0], + ref: innerRef, + onClick: menuIsOpen ? onMenuClose : onMenuOpen, + onBlur: onMenuClose, + disabled: !!selectProps.disabled, + invalid: !!selectProps.invalid, + }); + } + + return ( + + {children} + + ); +}; + +export function SelectBase({ + value, + defaultValue, + options = [], + onChange, + onBlur, + onCloseMenu, + onOpenMenu, + placeholder = 'Choose', + getOptionValue, + getOptionLabel, + isSearchable = true, + disabled = false, + isClearable = false, + isMulti = false, + isLoading = false, + isOpen, + autoFocus = false, + openMenuOnFocus = false, + maxMenuHeight = 300, + noOptionsMessage = 'No options found', + tabSelectsValue = true, + backspaceRemovesValue = true, + allowCustomValue = false, + size = 'auto', + prefix, + formatCreateLabel, + loadOptions, + loadingMessage = 'Loading options...', + defaultOptions, + renderControl, + width, + invalid, + components, +}: SelectBaseProps) { + const theme = useTheme(); + const styles = getSelectStyles(theme); + let Component: ReactSelect | Creatable = ReactSelect; + const creatableProps: any = {}; + let asyncSelectProps: any = {}; + + let selectedValue = []; + if (isMulti && loadOptions) { + selectedValue = value as any; + } else { + // If option is passed as a plain value (value property from SelectableValue property) + // we are selecting the corresponding value from the options + if (isMulti && value && Array.isArray(value) && !loadOptions) { + // @ts-ignore + selectedValue = value.map(v => { + return options.filter(o => { + return v === o.value || o.value === v.value; + })[0]; + }); + } else { + selectedValue = options.filter(o => o.value === value || o === value); + } + } + + const commonSelectProps = { + autoFocus, + placeholder, + isSearchable, + // Passing isDisabled as react-select accepts this prop + isDisabled: disabled, + // Also passing disabled, as this is the new Select API, and I want to use this prop instead of react-select's one + disabled, + invalid, + prefix, + isClearable, + isLoading, + menuIsOpen: isOpen, + defaultValue, + value: isMulti ? selectedValue : selectedValue[0], + getOptionLabel, + getOptionValue, + openMenuOnFocus, + maxMenuHeight, + isMulti, + backspaceRemovesValue, + onMenuOpen: onOpenMenu, + onMenuClose: onCloseMenu, + tabSelectsValue, + options, + onChange, + onBlur, + menuShouldScrollIntoView: false, + renderControl, + }; + + // width property is deprecated in favor of size or className + let widthClass = ''; + if (width) { + deprecationWarning('Select', 'width property', 'size or className'); + widthClass = 'width-' + width; + } + + if (allowCustomValue) { + Component = Creatable; + creatableProps.formatCreateLabel = formatCreateLabel ?? ((input: string) => `Create: ${input}`); + } + + // Instead of having AsyncSelect, as a separate component we render ReactAsyncSelect + if (loadOptions) { + Component = ReactAsyncSelect; + asyncSelectProps = { + loadOptions, + defaultOptions, + }; + } + + return ( + <>, + Control: CustomControl, + Option: SelectMenuOptions, + ClearIndicator: (props: any) => { + const { clearValue } = props; + return ( + { + e.preventDefault(); + e.stopPropagation(); + clearValue(); + }} + /> + ); + }, + LoadingIndicator: (props: any) => { + return ; + }, + LoadingMessage: (props: any) => { + return
{loadingMessage}
; + }, + NoOptionsMessage: (props: any) => { + return ( +
+ {noOptionsMessage} +
+ ); + }, + DropdownIndicator: (props: any) => , + SingleValue: SingleValue, + MultiValueContainer: MultiValueContainer, + MultiValueRemove: MultiValueRemove, + ...components, + }} + styles={{ + ...resetSelectStyles(), + singleValue: () => { + return css` + overflow: hidden; + `; + }, + container: () => { + return css` + position: relative; + ${inputSizes()[size]} + `; + }, + placeholder: () => { + return css` + display: inline-block; + color: hsl(0, 0%, 50%); + position: absolute; + top: 50%; + transform: translateY(-50%); + box-sizing: border-box; + line-height: 1; + `; + }, + }} + className={widthClass} + {...commonSelectProps} + {...creatableProps} + {...asyncSelectProps} + /> + ); +} diff --git a/packages/grafana-ui/src/components/Forms/Select/SelectMenu.tsx b/packages/grafana-ui/src/components/Forms/Select/SelectMenu.tsx new file mode 100644 index 00000000000..71ce1a2f970 --- /dev/null +++ b/packages/grafana-ui/src/components/Forms/Select/SelectMenu.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { useTheme } from '../../../themes/ThemeContext'; +import { getSelectStyles } from './getSelectStyles'; +import { cx } from 'emotion'; +import { SelectableValue } from '@grafana/data'; +import { Icon } from '../../Icon/Icon'; +import { CustomScrollbar } from '../../CustomScrollbar/CustomScrollbar'; +interface SelectMenuProps { + maxHeight: number; + innerRef: React.Ref; +} + +export const SelectMenu = React.forwardRef>((props, ref) => { + const theme = useTheme(); + const styles = getSelectStyles(theme); + const { children, maxHeight, innerRef } = props; + + return ( +
+ + {children} + +
+ ); +}); + +SelectMenu.displayName = 'SelectMenu'; + +interface SelectMenuOptionProps { + isDisabled: boolean; + isFocused: boolean; + isSelected: boolean; + innerProps: any; + renderOptionLabel?: (value: SelectableValue) => JSX.Element; + data: SelectableValue; +} + +export const SelectMenuOptions = React.forwardRef>>( + (props, ref) => { + const theme = useTheme(); + const styles = getSelectStyles(theme); + const { children, innerProps, data, renderOptionLabel, isSelected, isFocused } = props; + return ( +
+ {renderOptionLabel ? renderOptionLabel(data) : children} + {isSelected && ( + + + + )} +
+ ); + } +); diff --git a/packages/grafana-ui/src/components/Forms/Select/SelectOptionGroup.tsx b/packages/grafana-ui/src/components/Forms/Select/SelectOptionGroup.tsx new file mode 100644 index 00000000000..465eb973e36 --- /dev/null +++ b/packages/grafana-ui/src/components/Forms/Select/SelectOptionGroup.tsx @@ -0,0 +1,95 @@ +import React, { PureComponent } from 'react'; +import { css, cx } from 'emotion'; +import { GrafanaTheme } from '@grafana/data'; +import { GroupProps } from 'react-select/lib/components/Group'; +import { stylesFactory, withTheme, selectThemeVariant } from '../../../themes'; +import { Themeable } from '../../../types'; + +interface ExtendedGroupProps extends GroupProps, Themeable { + data: { + label: string; + expanded: boolean; + options: any[]; + }; +} + +interface State { + expanded: boolean; +} + +const getSelectOptionGroupStyles = stylesFactory((theme: GrafanaTheme) => { + const optionBorder = selectThemeVariant( + { + light: theme.colors.gray4, + dark: theme.colors.dark9, + }, + theme.type + ); + return { + header: css` + display: flex; + align-items: center; + justify-content: flex-start; + justify-items: center; + cursor: pointer; + padding: 7px 10px; + width: 100%; + border-bottom: 1px solid ${optionBorder}; + text-transform: capitalize; + `, + label: css` + flex-grow: 1; + `, + icon: css` + padding-right: 2px; + `, + }; +}); + +class UnthemedSelectOptionGroup extends PureComponent { + state = { + expanded: false, + }; + + componentDidMount() { + if (this.props.data.expanded) { + this.setState({ expanded: true }); + } else if (this.props.selectProps && this.props.selectProps.value) { + const { value } = this.props.selectProps.value; + + if (value && this.props.options.some(option => option.value === value)) { + this.setState({ expanded: true }); + } + } + } + + componentDidUpdate(nextProps: ExtendedGroupProps) { + if (nextProps.selectProps.inputValue !== '') { + this.setState({ expanded: true }); + } + } + + onToggleChildren = () => { + this.setState(prevState => ({ + expanded: !prevState.expanded, + })); + }; + + render() { + const { children, label, theme } = this.props; + const { expanded } = this.state; + const styles = getSelectOptionGroupStyles(theme); + const icon = expanded ? 'fa-caret-left' : 'fa-caret-down'; + return ( +
+
+ {label} + {' '} +
+ {expanded && children} +
+ ); + } +} + +export const SelectOptionGroup = withTheme(UnthemedSelectOptionGroup); diff --git a/packages/grafana-ui/src/components/Select/SingleValue.tsx b/packages/grafana-ui/src/components/Forms/Select/SingleValue.tsx similarity index 64% rename from packages/grafana-ui/src/components/Select/SingleValue.tsx rename to packages/grafana-ui/src/components/Forms/Select/SingleValue.tsx index e8d44551b12..dfc4688416b 100644 --- a/packages/grafana-ui/src/components/Select/SingleValue.tsx +++ b/packages/grafana-ui/src/components/Forms/Select/SingleValue.tsx @@ -1,16 +1,27 @@ import React from 'react'; -import { css, cx } from 'emotion'; +import { css } from 'emotion'; // Ignoring because I couldn't get @types/react-select work wih Torkel's fork // @ts-ignore import { components } from '@torkelo/react-select'; -import { useDelayedSwitch } from '../../utils/useDelayedSwitch'; -import { stylesFactory } from '../../themes'; -import { SlideOutTransition } from '../transitions/SlideOutTransition'; -import { FadeTransition } from '../transitions/FadeTransition'; -import { Spinner } from '../Spinner/Spinner'; +import { useDelayedSwitch } from '../../../utils/useDelayedSwitch'; +import { stylesFactory, useTheme } from '../../../themes'; +import { SlideOutTransition } from '../../transitions/SlideOutTransition'; +import { FadeTransition } from '../../transitions/FadeTransition'; +import { Spinner } from '../../Spinner/Spinner'; +import { GrafanaTheme } from '@grafana/data'; -const getStyles = stylesFactory(() => { +const getStyles = stylesFactory((theme: GrafanaTheme) => { + const singleValue = css` + label: singleValue; + color: ${theme.colors.formInputText}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + box-sizing: border-box; + max-width: 100%; + /* padding-right: 40px; */ + `; const container = css` width: 16px; height: 16px; @@ -27,7 +38,7 @@ const getStyles = stylesFactory(() => { position: absolute; `; - return { container, item }; + return { singleValue, container, item }; }); type Props = { @@ -41,13 +52,14 @@ type Props = { export const SingleValue = (props: Props) => { const { children, data } = props; - const styles = getStyles(); + const theme = useTheme(); + const styles = getStyles(theme); const loading = useDelayedSwitch(data.loading || false, { delay: 250, duration: 750 }); return ( -
+
{data.imgUrl ? ( ) : ( @@ -64,7 +76,8 @@ export const SingleValue = (props: Props) => { }; const FadeWithImage = (props: { loading: boolean; imgUrl: string }) => { - const styles = getStyles(); + const theme = useTheme(); + const styles = getStyles(theme); return (
diff --git a/packages/grafana-ui/src/components/Forms/Select/ValueContainer.tsx b/packages/grafana-ui/src/components/Forms/Select/ValueContainer.tsx new file mode 100644 index 00000000000..30391b29228 --- /dev/null +++ b/packages/grafana-ui/src/components/Forms/Select/ValueContainer.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { cx } from 'emotion'; +import { useTheme } from '../../../themes/ThemeContext'; +import { getSelectStyles } from './getSelectStyles'; + +export const ValueContainer = (props: any) => { + const theme = useTheme(); + const styles = getSelectStyles(theme); + const { children, isMulti } = props; + return
{children}
; +}; diff --git a/packages/grafana-ui/src/components/Forms/Select/getSelectStyles.ts b/packages/grafana-ui/src/components/Forms/Select/getSelectStyles.ts new file mode 100644 index 00000000000..e031b6185da --- /dev/null +++ b/packages/grafana-ui/src/components/Forms/Select/getSelectStyles.ts @@ -0,0 +1,81 @@ +import { stylesFactory } from '../../../themes/stylesFactory'; +import { selectThemeVariant as stv } from '../../../themes/selectThemeVariant'; +import { css } from 'emotion'; +import { GrafanaTheme } from '@grafana/data'; + +export const getSelectStyles = stylesFactory((theme: GrafanaTheme) => { + const bgColor = stv({ light: theme.colors.white, dark: theme.colors.gray15 }, theme.type); + const menuShadowColor = stv({ light: theme.colors.gray4, dark: theme.colors.black }, theme.type); + const optionBgHover = stv({ light: theme.colors.gray7, dark: theme.colors.gray10 }, theme.type); + const multiValueContainerBg = stv({ light: theme.colors.gray6, dark: theme.colors.gray05 }, theme.type); + const multiValueColor = stv({ light: theme.colors.gray25, dark: theme.colors.gray85 }, theme.type); + + return { + menu: css` + background: ${bgColor}; + box-shadow: 0px 4px 4px ${menuShadowColor}; + position: absolute; + min-width: 100%; + `, + option: css` + padding: 8px; + display: flex; + justify-content: space-between; + align-items: center; + flex-direction: row; + white-space: nowrap; + cursor: pointer; + &:hover { + background: ${optionBgHover}; + } + `, + optionFocused: css` + background: ${optionBgHover}; + border-image: linear-gradient(#f05a28 30%, #fbca0a 99%); + border-image-slice: 1; + border-style: solid; + border-top: 0; + border-right: 0; + border-bottom: 0; + border-left-width: 2px; + `, + singleValue: css` + color: ${theme.colors.formInputText}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + box-sizing: border-box; + max-width: 100%; + `, + valueContainer: css` + align-items: center; + display: flex; + position: relative; + box-sizing: border-box; + flex: 1 1 0%; + outline: none; + overflow: hidden; + `, + valueContainerMulti: css` + flex-wrap: wrap; + `, + loadingMessage: css` + padding: ${theme.spacing.sm}; + text-align: center; + width: 100%; + `, + multiValueContainer: css` + display: flex; + align-items: center; + line-height: 1; + background: ${multiValueContainerBg}; + border-radius: ${theme.border.radius.sm}; + padding: ${theme.spacing.xs} ${theme.spacing.xxs} ${theme.spacing.xs} ${theme.spacing.sm}; + margin: ${theme.spacing.xxs} ${theme.spacing.xs} ${theme.spacing.xxs} 0; + color: ${multiValueColor}; + `, + multiValueRemove: css` + margin-left: ${theme.spacing.xs}; + `, + }; +}); diff --git a/packages/grafana-ui/src/components/Select/resetSelectStyles.ts b/packages/grafana-ui/src/components/Forms/Select/resetSelectStyles.ts similarity index 100% rename from packages/grafana-ui/src/components/Select/resetSelectStyles.ts rename to packages/grafana-ui/src/components/Forms/Select/resetSelectStyles.ts diff --git a/packages/grafana-ui/src/components/Forms/commonStyles.ts b/packages/grafana-ui/src/components/Forms/commonStyles.ts index 6d6d850824d..6890a0ee0d4 100644 --- a/packages/grafana-ui/src/components/Forms/commonStyles.ts +++ b/packages/grafana-ui/src/components/Forms/commonStyles.ts @@ -25,9 +25,10 @@ export const sharedInputStyle = (theme: GrafanaTheme, invalid = false) => { font-size: ${theme.typography.size.md}; color: ${colors.formInputText}; border: 1px solid ${borderColor}; + padding: 0 ${theme.spacing.sm} 0 ${theme.spacing.sm}; &:hover { - border-color: ${colors.formInputBorder}; + border-color: ${borderColor}; } &:focus { @@ -53,7 +54,7 @@ export const inputSizes = () => { width: 580px; `, auto: css` - width: 100%; + width: auto; `, }; }; diff --git a/packages/grafana-ui/src/components/Forms/index.ts b/packages/grafana-ui/src/components/Forms/index.ts index f5d66c2a706..f6232fa773a 100644 --- a/packages/grafana-ui/src/components/Forms/index.ts +++ b/packages/grafana-ui/src/components/Forms/index.ts @@ -1,6 +1,7 @@ import { getFormStyles } from './getFormStyles'; import { Label } from './Label'; import { Input } from './Input/Input'; +import { Select } from './Select/Select'; import { Form } from './Form'; import { Field } from './Field'; import { Button } from './Button'; @@ -12,6 +13,7 @@ const Forms = { Form, Field, Button, + Select, }; export default Forms; diff --git a/packages/grafana-ui/src/components/Icon/Icon.tsx b/packages/grafana-ui/src/components/Icon/Icon.tsx index ebe53ddc332..b986e6de678 100644 --- a/packages/grafana-ui/src/components/Icon/Icon.tsx +++ b/packages/grafana-ui/src/components/Icon/Icon.tsx @@ -6,6 +6,8 @@ import { IconType } from './types'; export interface IconProps { name: IconType; className?: string; + onClick?: () => void; + onMouseDown?: React.MouseEventHandler; } const getIconStyles = stylesFactory(() => { @@ -23,9 +25,9 @@ const getIconStyles = stylesFactory(() => { }; }); -export const Icon: React.FC = ({ name, className }) => { +export const Icon: React.FC = ({ name, className, onClick, onMouseDown }) => { const styles = getIconStyles(); - return ; + return ; }; Icon.displayName = 'Icon'; diff --git a/packages/grafana-ui/src/components/Select/Select.story.tsx b/packages/grafana-ui/src/components/Select/Select.story.tsx index 6532b250230..4fe66e4e5f5 100644 --- a/packages/grafana-ui/src/components/Select/Select.story.tsx +++ b/packages/grafana-ui/src/components/Select/Select.story.tsx @@ -24,7 +24,7 @@ SelectStories.add('default', () => { {(value, updateValue) => { return ( { diff --git a/packages/grafana-ui/src/components/Select/Select.tsx b/packages/grafana-ui/src/components/Select/Select.tsx index a5d5045fc27..b9589842a18 100644 --- a/packages/grafana-ui/src/components/Select/Select.tsx +++ b/packages/grafana-ui/src/components/Select/Select.tsx @@ -14,54 +14,37 @@ import { components } from '@torkelo/react-select'; // Components import { SelectOption } from './SelectOption'; -import { SingleValue } from './SingleValue'; -import SelectOptionGroup from './SelectOptionGroup'; +import { SelectOptionGroup } from '../Forms/Select/SelectOptionGroup'; +import { SingleValue } from '../Forms/Select/SingleValue'; +import { SelectCommonProps, SelectAsyncProps } from '../Forms/Select/SelectBase'; import IndicatorsContainer from './IndicatorsContainer'; import NoOptionsMessage from './NoOptionsMessage'; -import resetSelectStyles from './resetSelectStyles'; +import resetSelectStyles from '../Forms/Select/resetSelectStyles'; import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar'; import { PopoverContent } from '../Tooltip/Tooltip'; import { Tooltip } from '../Tooltip/Tooltip'; import { SelectableValue } from '@grafana/data'; -export interface CommonProps { - defaultValue?: any; - getOptionLabel?: (item: SelectableValue) => string; - getOptionValue?: (item: SelectableValue) => string; - onChange: (item: SelectableValue) => {} | void; - placeholder?: string; - width?: number; - value?: SelectableValue; - className?: string; - isDisabled?: boolean; - isSearchable?: boolean; - isClearable?: boolean; - autoFocus?: boolean; - openMenuOnFocus?: boolean; - onBlur?: () => void; - maxMenuHeight?: number; - isLoading?: boolean; - noOptionsMessage?: () => string; - isMulti?: boolean; - backspaceRemovesValue?: boolean; - isOpen?: boolean; - components?: any; - tooltipContent?: PopoverContent; - onOpenMenu?: () => void; - onCloseMenu?: () => void; - tabSelectsValue?: boolean; - formatCreateLabel?: (input: string) => string; - allowCustomValue: boolean; -} +/** + * Changes in new selects: + * - noOptionsMessage & loadingMessage is of string type + * - isDisabled is renamed to disabled + */ +type LegacyCommonProps = Omit, 'noOptionsMessage' | 'disabled' | 'value'>; -export interface SelectProps extends CommonProps { - options: Array>; -} - -interface AsyncProps extends CommonProps { - defaultOptions: boolean; - loadOptions: (query: string) => Promise>>; +interface AsyncProps extends LegacyCommonProps, Omit, 'loadingMessage'> { loadingMessage?: () => string; + noOptionsMessage?: () => string; + tooltipContent?: PopoverContent; + isDisabled?: boolean; + value?: SelectableValue; +} + +interface LegacySelectProps extends LegacyCommonProps { + tooltipContent?: PopoverContent; + noOptionsMessage?: () => string; + isDisabled?: boolean; + value?: SelectableValue; } export const MenuList = (props: any) => { @@ -73,9 +56,8 @@ export const MenuList = (props: any) => { ); }; - -export class Select extends PureComponent> { - static defaultProps: Partial> = { +export class Select extends PureComponent> { + static defaultProps: Partial> = { className: '', isDisabled: false, isSearchable: true, @@ -144,7 +126,6 @@ export class Select extends PureComponent> { const selectClassNames = classNames('gf-form-input', 'gf-form-input--form-dropdown', widthClass, className); const selectComponents = { ...Select.defaultProps.components, ...components }; - return ( {(onOpenMenuInternal, onCloseMenuInternal) => { @@ -170,7 +151,7 @@ export class Select extends PureComponent> { onBlur={onBlur} openMenuOnFocus={openMenuOnFocus} maxMenuHeight={maxMenuHeight} - noOptionsMessage={noOptionsMessage} + noOptionsMessage={() => noOptionsMessage} isMulti={isMulti} backspaceRemovesValue={backspaceRemovesValue} menuIsOpen={isOpen} @@ -262,7 +243,7 @@ export class AsyncSelect extends PureComponent> { defaultOptions={defaultOptions} placeholder={placeholder || 'Choose'} styles={resetSelectStyles()} - loadingMessage={loadingMessage} + loadingMessage={() => loadingMessage} noOptionsMessage={noOptionsMessage} isDisabled={isDisabled} isSearchable={isSearchable} diff --git a/packages/grafana-ui/src/components/Select/SelectOptionGroup.tsx b/packages/grafana-ui/src/components/Select/SelectOptionGroup.tsx deleted file mode 100644 index 9a787a84819..00000000000 --- a/packages/grafana-ui/src/components/Select/SelectOptionGroup.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React, { PureComponent } from 'react'; -import { GroupProps } from 'react-select/lib/components/Group'; - -interface ExtendedGroupProps extends GroupProps { - data: { - label: string; - expanded: boolean; - options: any[]; - }; -} - -interface State { - expanded: boolean; -} - -export default class SelectOptionGroup extends PureComponent { - state = { - expanded: false, - }; - - componentDidMount() { - if (this.props.data.expanded) { - this.setState({ expanded: true }); - } else if (this.props.selectProps && this.props.selectProps.value) { - const { value } = this.props.selectProps.value; - - if (value && this.props.options.some(option => option.value === value)) { - this.setState({ expanded: true }); - } - } - } - - componentDidUpdate(nextProps: ExtendedGroupProps) { - if (nextProps.selectProps.inputValue !== '') { - this.setState({ expanded: true }); - } - } - - onToggleChildren = () => { - this.setState(prevState => ({ - expanded: !prevState.expanded, - })); - }; - - render() { - const { children, label } = this.props; - const { expanded } = this.state; - - return ( -
-
- {label} - {' '} -
- {expanded && children} -
- ); - } -} diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 85fc25ed7f9..4783aebbdbb 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -12,7 +12,7 @@ export * from './Button/Button'; export { Select, AsyncSelect } from './Select/Select'; export { IndicatorsContainer } from './Select/IndicatorsContainer'; export { NoOptionsMessage } from './Select/NoOptionsMessage'; -export { default as resetSelectStyles } from './Select/resetSelectStyles'; +export { default as resetSelectStyles } from './Forms/Select/resetSelectStyles'; export { ButtonSelect } from './Select/ButtonSelect'; export { Cascader, CascaderOption } from './Cascader/Cascader'; diff --git a/packages/grafana-ui/src/utils/storybook/knobs.ts b/packages/grafana-ui/src/utils/storybook/knobs.ts new file mode 100644 index 00000000000..1622ebf0d93 --- /dev/null +++ b/packages/grafana-ui/src/utils/storybook/knobs.ts @@ -0,0 +1,16 @@ +import { select } from '@storybook/addon-knobs'; +import { getAvailableIcons } from '../../components/Icon/types'; + +const VISUAL_GROUP = 'Visual options'; + +const iconOptions = { + None: undefined, + ...getAvailableIcons().reduce>((prev, c) => { + return { + ...prev, + [`Icon: ${c}`]: `${c}`, + }; + }, {}), +}; + +export const getIconKnob = () => select('Icon', iconOptions, undefined, VISUAL_GROUP); diff --git a/public/app/plugins/datasource/stackdriver/components/__snapshots__/QueryEditor.test.tsx.snap b/public/app/plugins/datasource/stackdriver/components/__snapshots__/QueryEditor.test.tsx.snap index cec241d1f1e..2b78e488994 100644 --- a/public/app/plugins/datasource/stackdriver/components/__snapshots__/QueryEditor.test.tsx.snap +++ b/public/app/plugins/datasource/stackdriver/components/__snapshots__/QueryEditor.test.tsx.snap @@ -361,7 +361,7 @@ Array [ className="css-0 gf-form-select-box__single-value" >
stackdriver auto