diff --git a/packages/grafana-ui/src/components/Forms/Select/ButtonSelect.tsx b/packages/grafana-ui/src/components/Forms/Select/ButtonSelect.tsx index 4d9f7c530e0..bf2ae67c177 100644 --- a/packages/grafana-ui/src/components/Forms/Select/ButtonSelect.tsx +++ b/packages/grafana-ui/src/components/Forms/Select/ButtonSelect.tsx @@ -1,13 +1,14 @@ import React from 'react'; +import { css } from 'emotion'; +import { GrafanaTheme } from '@grafana/data'; import { Button, ButtonVariant, ButtonProps } from '../Button'; import { ButtonSize } from '../../Button/types'; -import { SelectCommonProps, SelectBase, CustomControlProps } from './SelectBase'; -import { css } from 'emotion'; +import { SelectCommonProps, CustomControlProps } from './types'; +import { SelectBase } from './SelectBase'; import { stylesFactory, useTheme } from '../../../themes'; import { Icon } from '../../Icon/Icon'; import { IconType } from '../../Icon/types'; -import { GrafanaTheme } from '@grafana/data'; interface ButtonSelectProps extends Omit, 'renderControl' | 'size' | 'prefix'> { icon?: IconType; diff --git a/packages/grafana-ui/src/components/Forms/Select/Select.tsx b/packages/grafana-ui/src/components/Forms/Select/Select.tsx index 06a25b794da..d2e2e62106d 100644 --- a/packages/grafana-ui/src/components/Forms/Select/Select.tsx +++ b/packages/grafana-ui/src/components/Forms/Select/Select.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { SelectableValue } from '@grafana/data'; -import { SelectCommonProps, SelectBase, MultiSelectCommonProps, SelectAsyncProps } from './SelectBase'; +import { SelectCommonProps, MultiSelectCommonProps, SelectAsyncProps } from './types'; +import { SelectBase } from './SelectBase'; export function Select(props: SelectCommonProps) { return ; diff --git a/packages/grafana-ui/src/components/Forms/Select/SelectBase.tsx b/packages/grafana-ui/src/components/Forms/Select/SelectBase.tsx index 2b0f55f5f16..d7d9d4aae4c 100644 --- a/packages/grafana-ui/src/components/Forms/Select/SelectBase.tsx +++ b/packages/grafana-ui/src/components/Forms/Select/SelectBase.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react'; -import { SelectableValue, deprecationWarning } from '@grafana/data'; +import { deprecationWarning } from '@grafana/data'; // @ts-ignore import { default as ReactSelect } from '@torkelo/react-select'; // @ts-ignore @@ -12,7 +12,6 @@ import { default as AsyncCreatable } from '@torkelo/react-select/async-creatable import { Icon } from '../../Icon/Icon'; import { css, cx } from 'emotion'; import { inputSizesPixels } from '../commonStyles'; -import { FormInputSize } from '../types'; import resetSelectStyles from './resetSelectStyles'; import { SelectMenu, SelectMenuOptions } from './SelectMenu'; import { IndicatorsContainer } from './IndicatorsContainer'; @@ -24,81 +23,8 @@ 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; - inputValue?: string; - value?: SelectValue; - getOptionLabel?: (item: SelectableValue) => string; - getOptionValue?: (item: SelectableValue) => string; - onCreateOption?: (value: string) => void; - onChange: (value: SelectableValue) => {} | void; - onInputChange?: (label: string) => void; - onKeyDown?: (event: React.KeyboardEvent) => 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; - /** Use a custom element to control Select. A proper ref to the renderControl is needed if 'portal' isn't set to null*/ - renderControl?: ControlComponent; - menuPosition?: 'fixed' | 'absolute'; -} - -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>; +import { cleanValue } from './utils'; +import { SelectBaseProps, SelectValue } from './types'; const CustomControl = (props: any) => { const { @@ -209,7 +135,7 @@ export function SelectBase({ const hasValue = defaultValue || value; selectedValue = hasValue ? [hasValue] : []; } else { - selectedValue = options.filter(o => o.value === value || o === value); + selectedValue = cleanValue(value, options); } } diff --git a/packages/grafana-ui/src/components/Forms/Select/types.ts b/packages/grafana-ui/src/components/Forms/Select/types.ts new file mode 100644 index 00000000000..994963a2077 --- /dev/null +++ b/packages/grafana-ui/src/components/Forms/Select/types.ts @@ -0,0 +1,88 @@ +import { SelectableValue } from '@grafana/data'; +import React from 'react'; +import { FormInputSize } from '../types'; + +export type SelectValue = T | SelectableValue | T[] | Array>; + +export interface SelectCommonProps { + className?: string; + options?: Array>; + defaultValue?: any; + inputValue?: string; + value?: SelectValue; + getOptionLabel?: (item: SelectableValue) => string; + getOptionValue?: (item: SelectableValue) => string; + onCreateOption?: (value: string) => void; + onChange: (value: SelectableValue) => {} | void; + onInputChange?: (label: string) => void; + onKeyDown?: (event: React.KeyboardEvent) => 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; + /** Use a custom element to control Select. A proper ref to the renderControl is needed if 'portal' isn't set to null*/ + renderControl?: ControlComponent; + menuPosition?: 'fixed' | 'absolute'; +} + +export interface SelectAsyncProps { + /** When specified as boolean the loadOptions will execute when component is mounted */ + defaultOptions?: boolean | Array>; + /** Asynchronously 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>; + +export interface SelectableOptGroup { + label: string; + options: Array>; + [key: string]: any; +} + +export type SelectOptions = + | SelectableValue + | Array | SelectableOptGroup | Array>>; diff --git a/packages/grafana-ui/src/components/Forms/Select/utils.test.ts b/packages/grafana-ui/src/components/Forms/Select/utils.test.ts new file mode 100644 index 00000000000..f045d56c7bd --- /dev/null +++ b/packages/grafana-ui/src/components/Forms/Select/utils.test.ts @@ -0,0 +1,84 @@ +import { cleanValue, findSelectedValue } from './utils'; +import { SelectableOptGroup } from './types'; +import { SelectableValue } from '@grafana/data'; + +const optGroup: SelectableOptGroup[] = [ + { + label: 'Group 1', + options: [ + { label: 'Group 1 - Option 1', value: 1 }, + { label: 'Group 1 - Option 2', value: 2 }, + ], + }, + { + label: 'Group 2', + options: [ + { label: 'Group 2 - Option 1', value: 11 }, + { label: 'Group 2 - Option 2', value: 12 }, + ], + }, + { + label: 'Group 3', + options: [ + { label: 'Group 4 - Option 1', value: 'test1' }, + { label: 'Group 4 - Option 2', value: 'test2' }, + ], + }, + { + label: 'Group 4', + options: [ + { label: 'Group 4 - Option 1', value: 'test3' }, + { label: 'Group 4 - Option 2', value: 'test4' }, + ], + }, +]; + +const options: SelectableValue[] = [ + { label: 'Option 1', value: 1 }, + { label: ' Option 2', value: 'test2' }, + { label: 'Option 3', value: 3 }, + { label: 'Option 4', value: 4 }, + { label: 'Option 5', value: 'test5' }, + { label: 'Option 6', value: 6 }, +]; + +describe('Forms.Select utils', () => { + describe('findSelected value', () => { + it('should find value of type number in array of optgroups', () => { + expect(findSelectedValue(11, optGroup)).toEqual({ label: 'Group 2 - Option 1', value: 11 }); + }); + + it('should find value of type string in array of optgroups', () => { + expect(findSelectedValue('test3', optGroup)).toEqual({ label: 'Group 4 - Option 1', value: 'test3' }); + }); + + it('should find the value of type number in array of options', () => { + expect(findSelectedValue(3, options)).toEqual({ label: 'Option 3', value: 3 }); + }); + it('should find the value of type string in array of options', () => { + expect(findSelectedValue('test5', options)).toEqual({ label: 'Option 5', value: 'test5' }); + }); + }); + + describe('cleanValue', () => { + it('should return filtered array of values for value of array type', () => { + const value = [null, { value: 'test', label: 'Test' }, undefined, undefined]; + expect(cleanValue(value, options)).toEqual([{ value: 'test', label: 'Test' }]); + }); + + it('should return array when value is a single object', () => { + expect(cleanValue({ value: 'test', label: 'Test' }, options)).toEqual([{ value: 'test', label: 'Test' }]); + }); + + it('should return correct value when value argument is a string', () => { + expect(cleanValue('test1', optGroup)).toEqual([{ label: 'Group 4 - Option 1', value: 'test1' }]); + expect(cleanValue(3, options)).toEqual([{ label: 'Option 3', value: 3 }]); + }); + it('should return empty array for null/undefined/empty values', () => { + expect(cleanValue([undefined], options)).toEqual([]); + expect(cleanValue(undefined, options)).toEqual([]); + expect(cleanValue(null, options)).toEqual([]); + expect(cleanValue('', options)).toEqual([]); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/Forms/Select/utils.ts b/packages/grafana-ui/src/components/Forms/Select/utils.ts new file mode 100644 index 00000000000..39bb33baf71 --- /dev/null +++ b/packages/grafana-ui/src/components/Forms/Select/utils.ts @@ -0,0 +1,39 @@ +import { SelectableValue } from '@grafana/data'; +import { SelectOptions } from './types'; + +/** + * Normalize the value format to SelectableValue[] | []. Only used for single select + */ +export const cleanValue = (value: any, options: SelectOptions): SelectableValue[] | [] => { + if (Array.isArray(value)) { + return value.filter(Boolean); + } + if (typeof value === 'object' && value !== null) { + return [value]; + } + if (typeof value === 'string' || typeof value === 'number') { + const selectedValue = findSelectedValue(value, options); + if (selectedValue) { + return [selectedValue]; + } + } + return []; +}; + +/** + * Find the label for a string|number value inside array of options or optgroups + */ +export const findSelectedValue = (value: string | number, options: SelectOptions): SelectableValue | null => { + for (const option of options) { + if ('options' in option) { + let found = findSelectedValue(value, option.options); + if (found) { + return found; + } + } else if ('value' in option && option.value === value) { + return option; + } + } + + return null; +}; diff --git a/packages/grafana-ui/src/components/Select/Select.tsx b/packages/grafana-ui/src/components/Select/Select.tsx index e59032b6d1d..594972b64d0 100644 --- a/packages/grafana-ui/src/components/Select/Select.tsx +++ b/packages/grafana-ui/src/components/Select/Select.tsx @@ -18,7 +18,7 @@ import { components } from '@torkelo/react-select'; import { SelectOption } from './SelectOption'; import { SelectOptionGroup } from '../Forms/Select/SelectOptionGroup'; import { SingleValue } from '../Forms/Select/SingleValue'; -import { SelectCommonProps, SelectAsyncProps } from '../Forms/Select/SelectBase'; +import { SelectCommonProps, SelectAsyncProps } from '../Forms/Select/types'; import IndicatorsContainer from './IndicatorsContainer'; import NoOptionsMessage from './NoOptionsMessage'; import resetSelectStyles from '../Forms/Select/resetSelectStyles'; diff --git a/packages/grafana-ui/src/components/TimePicker/TimeZonePicker.story.tsx b/packages/grafana-ui/src/components/TimePicker/TimeZonePicker.story.tsx index 943b2c551b9..6c064bd893b 100644 --- a/packages/grafana-ui/src/components/TimePicker/TimeZonePicker.story.tsx +++ b/packages/grafana-ui/src/components/TimePicker/TimeZonePicker.story.tsx @@ -14,7 +14,7 @@ TimeZonePickerStories.add('default', () => { return ( {(value, updateValue) => { @@ -25,7 +25,7 @@ TimeZonePickerStories.add('default', () => { action('on selected')(newValue); updateValue({ value: newValue }); }} - width={20} + size="sm" /> ); }} diff --git a/packages/grafana-ui/src/components/TimePicker/TimeZonePicker.tsx b/packages/grafana-ui/src/components/TimePicker/TimeZonePicker.tsx index 7d50a7fc738..131f354b062 100644 --- a/packages/grafana-ui/src/components/TimePicker/TimeZonePicker.tsx +++ b/packages/grafana-ui/src/components/TimePicker/TimeZonePicker.tsx @@ -1,15 +1,16 @@ import React, { FC } from 'react'; -import { getTimeZoneGroups, SelectableValue } from '@grafana/data'; -import { Select } from '../Select/Select'; +import { getTimeZoneGroups } from '@grafana/data'; +import { Cascader } from '../index'; +import { FormInputSize } from '../Forms/types'; interface Props { value: string; - width?: number; + size?: FormInputSize; onChange: (newValue: string) => void; } -export const TimeZonePicker: FC = ({ onChange, value, width }) => { +export const TimeZonePicker: FC = ({ onChange, value, size = 'md' }) => { const timeZoneGroups = getTimeZoneGroups(); const groupOptions = timeZoneGroups.map(group => { @@ -22,20 +23,26 @@ export const TimeZonePicker: FC = ({ onChange, value, width }) => { return { label: group.label, - options, + value: group.label, + items: options, }; }); - const selectedValue = groupOptions.map(group => { - return group.options.find(option => option.value === value); - }); + const selectedValue = groupOptions.reduce( + (acc, group) => { + const found = group.items.find(option => option.value === value); + return found || acc; + }, + { value: '' } + ); return ( -