Commit aa0982da by Tobias Skarhed Committed by GitHub

Add component: Cascader (#21410)

* Rename old cascader

* Change name of old cascader

* Add basic cascader without search

* Add basic cascader without search

* Flatten options to make it searchable

* Add regex search and make backspace work

* Add barebone search without styles

* Add SearchResult list

* Add search navigation

* Rewrite of cascader and add some things to SelectBase

* Make SelectBase controlllable

* Cleanup

* Add initial value functionality

* Add onblur to hand caret direction

* New storyboom format for ButtonCascader

* Add knobs to story

* Add story and docs for UnitPicker

* Make UnitPicker use Cascader

* Fix backspace issue and empty value

* Fix backspace issue for real

* Remove unused code

* Fix focus issue

* Change children to items and remove ButtonCascaderProps

* Remove local CascaderOption

* Fix failed test

* Revert UnitPicker changes and change format for ButtonCascader

* Fix failing tests
parent 20aac7f0
import React from 'react';
import { withKnobs, text, boolean, object } from '@storybook/addon-knobs';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { ButtonCascader } from './ButtonCascader';
export default {
title: 'UI/ButtonCascader',
component: ButtonCascader,
decorators: [withKnobs, withCenteredStory],
};
const getKnobs = () => {
return {
disabled: boolean('Disabled', false),
text: text('Button Text', 'Click me!'),
options: object('Options', [
{
label: 'A',
value: 'A',
children: [
{ label: 'B', value: 'B' },
{ label: 'C', value: 'C' },
],
},
{ label: 'D', value: 'D' },
]),
};
};
export const simple = () => {
const { disabled, text, options } = getKnobs();
return <ButtonCascader disabled={disabled} options={options} value={['A']} expandIcon={null} buttonText={text} />;
};
import React from 'react';
import { Button } from '../Forms/Button';
import { Icon } from '../Icon/Icon';
// @ts-ignore
import RCCascader from 'rc-cascader';
import { CascaderOption } from '../Cascader/Cascader';
export interface ButtonCascaderProps {
options: CascaderOption[];
buttonText: string;
disabled?: boolean;
expandIcon?: React.ReactNode;
value?: string[];
loadData?: (selectedOptions: CascaderOption[]) => void;
onChange?: (value: string[], selectedOptions: CascaderOption[]) => void;
onPopupVisibleChange?: (visible: boolean) => void;
}
export const ButtonCascader: React.FC<ButtonCascaderProps> = props => (
<RCCascader {...props} fieldNames={{ label: 'label', value: 'value', children: 'items' }}>
<Button variant="secondary" disabled={props.disabled}>
{props.buttonText} <Icon name="caret-down" />
</Button>
</RCCascader>
);
import { Meta, Story, Preview, Props } from '@storybook/addon-docs/blocks';
import { Cascader } from './Cascader';
# Cascader with search
<Meta title="MDX|Cascader" component={Cascader} />
<Props of={Cascader}/>
\ No newline at end of file
import React from 'react';
import { storiesOf } from '@storybook/react';
import { text, boolean, object } from '@storybook/addon-knobs';
import { text } from '@storybook/addon-knobs';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { Cascader } from './Cascader';
// import { Button } from '../Button';
import mdx from './Cascader.mdx';
import React from 'react';
const getKnobs = () => {
return {
disabled: boolean('Disabled', false),
text: text('Button Text', 'Click me!'),
options: object('Options', [
{
label: 'A',
value: 'A',
children: [
{ label: 'B', value: 'B' },
{ label: 'C', value: 'C' },
],
},
{ label: 'D', value: 'D' },
]),
};
export default {
title: 'UI/Cascader',
component: Cascader,
decorators: [withCenteredStory],
parameters: {
docs: {
page: mdx,
},
},
};
const CascaderStories = storiesOf('UI/Cascader', module);
CascaderStories.addDecorator(withCenteredStory);
const options = [
{
label: 'First',
value: '1',
items: [
{
label: 'Second',
value: '2',
},
{
label: 'Third',
value: '3',
},
{
label: 'Fourth',
value: '4',
},
],
},
{
label: 'FirstFirst',
value: '5',
},
];
CascaderStories.add('default', () => {
const { disabled, text, options } = getKnobs();
return <Cascader disabled={disabled} options={options} value={['A']} expandIcon={null} buttonText={text} />;
});
export const simple = () => (
<Cascader separator={text('Separator', '')} options={options} onSelect={val => console.log(val)} />
);
export const withInitialValue = () => (
<Cascader options={options} initialValue="3" onSelect={val => console.log(val)} />
);
import React from 'react';
import { Cascader } from './Cascader';
import { shallow } from 'enzyme';
const options = [
{
label: 'First',
value: '1',
items: [
{
label: 'Second',
value: '2',
},
{
label: 'Third',
value: '3',
},
{
label: 'Fourth',
value: '4',
},
],
},
{
label: 'FirstFirst',
value: '5',
},
];
const flatOptions = [
{
label: 'First / Second',
value: ['1', '2'],
},
{
label: 'First / Third',
value: ['1', '3'],
},
{
label: 'First / Fourth',
value: ['1', '4'],
},
{
label: 'FirstFirst',
value: ['5'],
},
];
describe('Cascader', () => {
let cascader: any;
beforeEach(() => {
cascader = shallow(<Cascader options={options} onSelect={() => {}} />);
});
it('Should convert options to searchable strings', () => {
expect(cascader.state('searchableOptions')).toEqual(flatOptions);
});
});
import React from 'react';
import { Icon } from '../Icon/Icon';
// @ts-ignore
import RCCascader from 'rc-cascader';
import { Select } from '../Forms/Select/Select';
import { FormInputSize } from '../Forms/types';
import { Input } from '../Forms/Input/Input';
import { SelectableValue } from '@grafana/data';
import { css } from 'emotion';
interface CascaderProps {
separator?: string;
options: CascaderOption[];
onSelect(val: string): void;
size?: FormInputSize;
initialValue?: string;
}
interface CascaderState {
isSearching: boolean;
searchableOptions: Array<SelectableValue<string[]>>;
focusCascade: boolean;
//Array for cascade navigation
rcValue: SelectableValue<string[]>;
activeLabel: string;
}
export interface CascaderOption {
value: any;
label: string;
value: string;
children?: CascaderOption[];
items?: CascaderOption[];
disabled?: boolean;
// Undocumented tooltip API
title?: string;
}
export interface CascaderProps {
options: CascaderOption[];
buttonText: string;
disabled?: boolean;
expandIcon?: React.ReactNode;
value?: string[];
loadData?: (selectedOptions: CascaderOption[]) => void;
onChange?: (value: string[], selectedOptions: CascaderOption[]) => void;
onPopupVisibleChange?: (visible: boolean) => void;
const disableDivFocus = css(`
&:focus{
outline: none;
}
`);
export class Cascader extends React.PureComponent<CascaderProps, CascaderState> {
constructor(props: CascaderProps) {
super(props);
const searchableOptions = this.flattenOptions(props.options);
const { rcValue, activeLabel } = this.setInitialValue(searchableOptions, props.initialValue);
this.state = {
isSearching: false,
focusCascade: false,
searchableOptions,
rcValue,
activeLabel,
};
}
flattenOptions = (options: CascaderOption[], optionPath: CascaderOption[] = []) => {
let selectOptions: Array<SelectableValue<string[]>> = [];
for (const option of options) {
const cpy = [...optionPath];
cpy.push(option);
if (!option.items) {
selectOptions.push({
label: cpy.map(o => o.label).join(this.props.separator || ' / '),
value: cpy.map(o => o.value),
});
} else {
selectOptions = [...selectOptions, ...this.flattenOptions(option.items, cpy)];
}
}
return selectOptions;
};
export const Cascader: React.FC<CascaderProps> = props => (
<RCCascader {...props}>
<button className="gf-form-label gf-form-label--btn" disabled={props.disabled}>
{props.buttonText} <i className="fa fa-caret-down" />
</button>
</RCCascader>
);
setInitialValue(searchableOptions: Array<SelectableValue<string[]>>, initValue?: string) {
if (!initValue) {
return { rcValue: [], activeLabel: '' };
}
for (const option of searchableOptions) {
const optionPath = option.value || [];
if (optionPath.indexOf(initValue) === optionPath.length - 1) {
return {
rcValue: optionPath,
activeLabel: option.label || '',
};
}
}
return { rcValue: [], activeLabel: '' };
}
//For rc-cascader
onChange = (value: string[], selectedOptions: CascaderOption[]) => {
this.setState({
rcValue: value,
activeLabel: selectedOptions.map(o => o.label).join(this.props.separator || ' / '),
});
this.props.onSelect(selectedOptions[selectedOptions.length - 1].value);
};
//For select
onSelect = (obj: SelectableValue<string[]>) => {
this.setState({
activeLabel: obj.label || '',
rcValue: obj.value || [],
isSearching: false,
});
this.props.onSelect(this.state.rcValue[this.state.rcValue.length - 1]);
};
onClick = () => {
this.setState({
focusCascade: true,
});
};
onBlur = () => {
this.setState({
isSearching: false,
focusCascade: false,
});
if (this.state.activeLabel === '') {
this.setState({
rcValue: [],
});
}
};
onBlurCascade = () => {
this.setState({
focusCascade: false,
});
};
onInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (
e.key !== 'ArrowDown' &&
e.key !== 'ArrowUp' &&
e.key !== 'Enter' &&
e.key !== 'ArrowLeft' &&
e.key !== 'ArrowRight'
) {
this.setState({
focusCascade: false,
isSearching: true,
});
if (e.key === 'Backspace') {
const label = this.state.activeLabel || '';
this.setState({
activeLabel: label.slice(0, -1),
});
}
}
};
onInputChange = (value: string) => {
this.setState({
activeLabel: value,
});
};
render() {
const { size } = this.props;
const { focusCascade, isSearching, searchableOptions, rcValue, activeLabel } = this.state;
return (
<div>
{isSearching ? (
<Select
inputValue={activeLabel}
placeholder="Search"
autoFocus={!focusCascade}
onChange={this.onSelect}
onInputChange={this.onInputChange}
onBlur={this.onBlur}
options={searchableOptions}
size={size || 'md'}
/>
) : (
<RCCascader
onChange={this.onChange}
onClick={this.onClick}
options={this.props.options}
isFocused={focusCascade}
onBlur={this.onBlurCascade}
value={rcValue}
fieldNames={{ label: 'label', value: 'value', children: 'items' }}
>
<div className={disableDivFocus}>
<Input
value={activeLabel}
onKeyDown={this.onInputKeyDown}
onChange={() => {}}
size={size || 'md'}
suffix={focusCascade ? <Icon name="caret-up" /> : <Icon name="caret-down" />}
/>
</div>
</RCCascader>
)}
</div>
);
}
}
......@@ -2,7 +2,7 @@ import React from 'react';
import { Button, ButtonVariant, ButtonProps } from '../Button';
import { ButtonSize } from '../../Button/types';
import { SelectCommonProps, SelectBase } from './SelectBase';
import { SelectCommonProps, SelectBase, CustomControlProps } from './SelectBase';
import { css } from 'emotion';
import { useTheme } from '../../../themes';
import { Icon } from '../../Icon/Icon';
......@@ -73,13 +73,13 @@ export function ButtonSelect<T>({
return (
<SelectBase
{...selectProps}
renderControl={({ onBlur, onClick, value, isOpen }) => {
renderControl={React.forwardRef<any, CustomControlProps<T>>(({ onBlur, onClick, value, isOpen }, _ref) => {
return (
<SelectButton {...buttonProps} onBlur={onBlur} onClick={onClick} isOpen={isOpen}>
{value ? value.label : placeholder}
</SelectButton>
);
}}
})}
/>
);
}
......@@ -27,10 +27,13 @@ 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;
onChange: (value: SelectableValue<T>) => {} | void;
onInputChange?: (label: string) => void;
onKeyDown?: (event: React.KeyboardEvent) => void;
placeholder?: string;
disabled?: boolean;
isSearchable?: boolean;
......@@ -131,9 +134,12 @@ const CustomControl = (props: any) => {
export function SelectBase<T>({
value,
defaultValue,
inputValue,
onInputChange,
options = [],
onChange,
onBlur,
onKeyDown,
onCloseMenu,
onOpenMenu,
placeholder = 'Choose',
......@@ -201,6 +207,8 @@ export function SelectBase<T>({
isLoading,
menuIsOpen: isOpen,
defaultValue,
inputValue,
onInputChange,
value: isMulti ? selectedValue : selectedValue[0],
getOptionLabel,
getOptionValue,
......@@ -214,6 +222,7 @@ export function SelectBase<T>({
options,
onChange,
onBlur,
onKeyDown,
menuShouldScrollIntoView: false,
renderControl,
};
......
import { Meta, Story, Preview, Props } from '@storybook/addon-docs/blocks';
import { UnitPicker } from './UnitPicker';
# UnitPicker
<Props of={UnitPicker}/>
\ No newline at end of file
import React from 'react';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { UnitPicker } from './UnitPicker';
import mdx from './UnitPicker.mdx';
export default {
title: 'UI/UnitPicker',
component: UnitPicker,
decorators: [withCenteredStory],
parameters: {
docs: mdx,
},
};
export const simple = () => <UnitPicker onChange={val => console.log(val)} />;
@import 'BarGauge/BarGauge';
@import 'Cascader/Cascader';
@import 'ButtonCascader/ButtonCascader';
@import 'ColorPicker/ColorPicker';
@import 'CustomScrollbar/CustomScrollbar';
@import 'Drawer/Drawer';
......
......@@ -14,6 +14,7 @@ export { IndicatorsContainer } from './Select/IndicatorsContainer';
export { NoOptionsMessage } from './Select/NoOptionsMessage';
export { default as resetSelectStyles } from './Forms/Select/resetSelectStyles';
export { ButtonSelect } from './Select/ButtonSelect';
export { ButtonCascader } from './ButtonCascader/ButtonCascader';
export { Cascader, CascaderOption } from './Cascader/Cascader';
// Forms
......
import React from 'react';
import { ExploreQueryFieldProps } from '@grafana/data';
import { Cascader, CascaderOption } from '@grafana/ui';
import { ButtonCascader, CascaderOption } from '@grafana/ui';
import InfluxQueryModel from '../influx_query_model';
import { AdHocFilterField, KeyValuePair } from 'app/features/explore/AdHocFilterField';
......@@ -75,7 +75,7 @@ export class InfluxLogsQueryField extends React.PureComponent<Props, State> {
measurements.push({
label: measurementObj.text,
value: measurementObj.text,
children: fields,
items: fields,
});
}
this.setState({ measurements });
......@@ -134,7 +134,7 @@ export class InfluxLogsQueryField extends React.PureComponent<Props, State> {
return (
<div className="gf-form-inline gf-form-inline--nowrap">
<div className="gf-form flex-shrink-0">
<Cascader
<ButtonCascader
buttonText={cascadeText}
options={measurements}
disabled={!hasMeasurement}
......
......@@ -2,7 +2,7 @@
import React from 'react';
import {
Cascader,
ButtonCascader,
CascaderOption,
SlatePrism,
TypeaheadOutput,
......@@ -148,7 +148,7 @@ export class LokiQueryFieldForm extends React.PureComponent<LokiQueryFieldFormPr
<>
<div className="gf-form-inline">
<div className="gf-form">
<Cascader
<ButtonCascader
options={logLabelOptions || []}
disabled={buttonDisabled}
buttonText={chooserText}
......
......@@ -9,7 +9,7 @@ describe('groupMetricsByPrefix()', () => {
expect(groupMetricsByPrefix(['foo_metric'])).toMatchObject([
{
value: 'foo',
children: [
items: [
{
value: 'foo_metric',
},
......@@ -22,7 +22,7 @@ describe('groupMetricsByPrefix()', () => {
expect(groupMetricsByPrefix(['foo_metric'], { foo_metric: [{ type: 'TYPE', help: 'my help' }] })).toMatchObject([
{
value: 'foo',
children: [
items: [
{
value: 'foo_metric',
title: 'foo_metric\nTYPE\nmy help',
......@@ -44,7 +44,7 @@ describe('groupMetricsByPrefix()', () => {
expect(groupMetricsByPrefix([':foo_metric:'])).toMatchObject([
{
value: RECORDING_RULES_GROUP,
children: [
items: [
{
value: ':foo_metric:',
},
......
......@@ -3,7 +3,7 @@ import React from 'react';
import { Plugin } from 'slate';
import {
Cascader,
ButtonCascader,
CascaderOption,
SlatePrism,
TypeaheadInput,
......@@ -52,7 +52,7 @@ export function groupMetricsByPrefix(metrics: string[], metadata?: PromMetricsMe
const rulesOption = {
label: 'Recording rules',
value: RECORDING_RULES_GROUP,
children: ruleNames
items: ruleNames
.slice()
.sort()
.map(name => ({ label: name, value: name })),
......@@ -69,7 +69,7 @@ export function groupMetricsByPrefix(metrics: string[], metadata?: PromMetricsMe
const prefixIsMetric = metricsForPrefix.length === 1 && metricsForPrefix[0] === prefix;
const children = prefixIsMetric ? [] : metricsForPrefix.sort().map(m => addMetricsMetadata(m, metadata));
return {
children,
items: children,
label: prefix,
value: prefix,
};
......@@ -198,7 +198,7 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
onChangeMetrics = (values: string[], selectedOptions: CascaderOption[]) => {
let query;
if (selectedOptions.length === 1) {
if (selectedOptions[0].children.length === 0) {
if (selectedOptions[0].items.length === 0) {
query = selectedOptions[0].value;
} else {
// Ignore click on group
......@@ -254,10 +254,7 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
const histogramOptions = histogramMetrics.map((hm: any) => ({ label: hm, value: hm }));
const metricsOptions =
histogramMetrics.length > 0
? [
{ label: 'Histograms', value: HISTOGRAM_GROUP, children: histogramOptions, isLeaf: false },
...metricsByPrefix,
]
? [{ label: 'Histograms', value: HISTOGRAM_GROUP, items: histogramOptions, isLeaf: false }, ...metricsByPrefix]
: metricsByPrefix;
// Hint for big disabled lookups
......@@ -302,7 +299,7 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
<>
<div className="gf-form-inline gf-form-inline--nowrap flex-grow-1">
<div className="gf-form flex-shrink-0">
<Cascader
<ButtonCascader
options={metricsOptions}
buttonText={chooserText}
disabled={buttonDisabled}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment