Form migrations: Dashboard- and TimeZonePicker (#22459)

* Add new form styles to dashboard picker

* Use Forms.Select for TimeZonePicker

* Use new form elements for DashboardPicker

* Grafana UI: Extract types from SelectBase, add utils.ts

* Grafana UI: Fix imports

* Grafana UI: Add support for value of type number

* Grafana UI: tweak value search function

* Grafana UI: Add tests for findSelectedValue

* Grafana UI: Add tests for cleanValue

* Grafana UI: Remove redundant check

* Grafana UI: Order imports

* Grafana-UI: Fix TimeZonePicker.story.tsx

* Grafana-UI: Fix timezone value

* Fix merge

* Grafana-UI: Use Cascader vs Forms.Select for TimeZonePicker

* Grafana-UI: Add default size props
This commit is contained in:
Alex Khomenko 2020-03-03 16:09:52 +02:00 committed by GitHub
parent d66e72fa67
commit 3a5375ddd8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 264 additions and 117 deletions

View File

@ -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<T> extends Omit<SelectCommonProps<T>, 'renderControl' | 'size' | 'prefix'> {
icon?: IconType;

View File

@ -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<T>(props: SelectCommonProps<T>) {
return <SelectBase {...props} />;

View File

@ -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> = T | SelectableValue<T> | T[] | Array<SelectableValue<T>>;
export interface SelectCommonProps<T> {
className?: string;
options?: Array<SelectableValue<T>>;
defaultValue?: any;
inputValue?: string;
value?: SelectValue<T>;
getOptionLabel?: (item: SelectableValue<T>) => string;
getOptionValue?: (item: SelectableValue<T>) => string;
onCreateOption?: (value: string) => void;
onChange: (value: SelectableValue<T>) => {} | 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<T>;
menuPosition?: 'fixed' | 'absolute';
}
export interface SelectAsyncProps<T> {
/** When specified as boolean the loadOptions will execute when component is mounted */
defaultOptions?: boolean | Array<SelectableValue<T>>;
/** Asynchroniously load select options */
loadOptions?: (query: string) => Promise<Array<SelectableValue<T>>>;
/** Message to display when options are loading */
loadingMessage?: string;
}
export interface MultiSelectCommonProps<T> extends Omit<SelectCommonProps<T>, 'onChange' | 'isMulti' | 'value'> {
value?: Array<SelectableValue<T>> | T[];
onChange: (item: Array<SelectableValue<T>>) => {} | void;
}
export interface SelectBaseProps<T> extends SelectCommonProps<T>, SelectAsyncProps<T> {
invalid?: boolean;
}
export interface CustomControlProps<T> {
ref: React.Ref<any>;
isOpen: boolean;
/** Currently selected value */
value?: SelectableValue<T>;
/** 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<T> = React.ComponentType<CustomControlProps<T>>;
import { cleanValue } from './utils';
import { SelectBaseProps, SelectValue } from './types';
const CustomControl = (props: any) => {
const {
@ -209,7 +135,7 @@ export function SelectBase<T>({
const hasValue = defaultValue || value;
selectedValue = hasValue ? [hasValue] : [];
} else {
selectedValue = options.filter(o => o.value === value || o === value);
selectedValue = cleanValue(value, options);
}
}

View File

@ -0,0 +1,88 @@
import { SelectableValue } from '@grafana/data';
import React from 'react';
import { FormInputSize } from '../types';
export type SelectValue<T> = T | SelectableValue<T> | T[] | Array<SelectableValue<T>>;
export interface SelectCommonProps<T> {
className?: string;
options?: Array<SelectableValue<T>>;
defaultValue?: any;
inputValue?: string;
value?: SelectValue<T>;
getOptionLabel?: (item: SelectableValue<T>) => string;
getOptionValue?: (item: SelectableValue<T>) => string;
onCreateOption?: (value: string) => void;
onChange: (value: SelectableValue<T>) => {} | 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<T>;
menuPosition?: 'fixed' | 'absolute';
}
export interface SelectAsyncProps<T> {
/** When specified as boolean the loadOptions will execute when component is mounted */
defaultOptions?: boolean | Array<SelectableValue<T>>;
/** Asynchronously load select options */
loadOptions?: (query: string) => Promise<Array<SelectableValue<T>>>;
/** Message to display when options are loading */
loadingMessage?: string;
}
export interface MultiSelectCommonProps<T> extends Omit<SelectCommonProps<T>, 'onChange' | 'isMulti' | 'value'> {
value?: Array<SelectableValue<T>> | T[];
onChange: (item: Array<SelectableValue<T>>) => {} | void;
}
export interface SelectBaseProps<T> extends SelectCommonProps<T>, SelectAsyncProps<T> {
invalid?: boolean;
}
export interface CustomControlProps<T> {
ref: React.Ref<any>;
isOpen: boolean;
/** Currently selected value */
value?: SelectableValue<T>;
/** 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<T> = React.ComponentType<CustomControlProps<T>>;
export interface SelectableOptGroup<T = any> {
label: string;
options: Array<SelectableValue<T>>;
[key: string]: any;
}
export type SelectOptions<T = any> =
| SelectableValue<T>
| Array<SelectableValue<T> | SelectableOptGroup<T> | Array<SelectableOptGroup<T>>>;

View File

@ -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([]);
});
});
});

View File

@ -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;
};

View File

@ -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';

View File

@ -14,7 +14,7 @@ TimeZonePickerStories.add('default', () => {
return (
<UseState
initialState={{
value: 'europe/stockholm',
value: 'Europe/Stockholm',
}}
>
{(value, updateValue) => {
@ -25,7 +25,7 @@ TimeZonePickerStories.add('default', () => {
action('on selected')(newValue);
updateValue({ value: newValue });
}}
width={20}
size="sm"
/>
);
}}

View File

@ -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<Props> = ({ onChange, value, width }) => {
export const TimeZonePicker: FC<Props> = ({ onChange, value, size = 'md' }) => {
const timeZoneGroups = getTimeZoneGroups();
const groupOptions = timeZoneGroups.map(group => {
@ -22,20 +23,26 @@ export const TimeZonePicker: FC<Props> = ({ 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 (
<Select
<Cascader
options={groupOptions}
value={selectedValue}
onChange={(newValue: SelectableValue) => onChange(newValue.value)}
width={width}
initialValue={selectedValue?.value}
onSelect={(newValue: string) => onChange(newValue)}
size={size}
placeholder="Select timezone"
/>
);
};

View File

@ -1,14 +1,15 @@
import React, { PureComponent } from 'react';
import { AsyncSelect } from '@grafana/ui';
import { SelectableValue } from '@grafana/data';
import { debounce } from 'lodash';
import { SelectableValue } from '@grafana/data';
import { Forms } from '@grafana/ui';
import { FormInputSize } from '@grafana/ui/src/components/Forms/types';
import { backendSrv } from 'app/core/services/backend_srv';
import { DashboardSearchHit, DashboardDTO } from 'app/types';
export interface Props {
className?: string;
onSelected: (dashboard: DashboardDTO) => void;
currentDashboardId: SelectableValue<number>;
currentDashboardId?: SelectableValue<number>;
size?: FormInputSize;
}
export interface State {
@ -18,6 +19,10 @@ export interface State {
export class DashboardPicker extends PureComponent<Props, State> {
debouncedSearch: any;
static defaultProps = {
size: 'md',
};
constructor(props: Props) {
super(props);
@ -46,25 +51,21 @@ export class DashboardPicker extends PureComponent<Props, State> {
};
render() {
const { className, onSelected, currentDashboardId } = this.props;
const { size, onSelected, currentDashboardId } = this.props;
const { isLoading } = this.state;
return (
<div className="gf-form-inline">
<div className="gf-form">
<AsyncSelect
className={className}
isLoading={isLoading}
isClearable={true}
defaultOptions={true}
loadOptions={this.debouncedSearch}
onChange={onSelected}
placeholder="Select dashboard"
noOptionsMessage={() => 'No dashboards found'}
value={currentDashboardId}
/>
</div>
</div>
<Forms.AsyncSelect
size={size}
isLoading={isLoading}
isClearable={true}
defaultOptions={true}
loadOptions={this.debouncedSearch}
onChange={onSelected}
placeholder="Select dashboard"
noOptionsMessage={'No dashboards found'}
value={currentDashboardId}
/>
);
}
}