mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
d66e72fa67
commit
3a5375ddd8
@ -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;
|
||||
|
@ -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} />;
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
88
packages/grafana-ui/src/components/Forms/Select/types.ts
Normal file
88
packages/grafana-ui/src/components/Forms/Select/types.ts
Normal 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>>>;
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
39
packages/grafana-ui/src/components/Forms/Select/utils.ts
Normal file
39
packages/grafana-ui/src/components/Forms/Select/utils.ts
Normal 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;
|
||||
};
|
@ -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';
|
||||
|
@ -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"
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user