From aa0982da56684bf2ad5c04fc16c7e1da43f7e05a Mon Sep 17 00:00:00 2001 From: Tobias Skarhed Date: Fri, 17 Jan 2020 11:30:33 +0100 Subject: [PATCH] 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 --- .../ButtonCascader/ButtonCascader.story.tsx | 33 +++ .../ButtonCascader/ButtonCascader.tsx | 27 +++ .../_ButtonCascader.scss} | 0 .../src/components/Cascader/Cascader.mdx | 8 + .../components/Cascader/Cascader.story.tsx | 69 +++--- .../src/components/Cascader/Cascader.test.tsx | 58 +++++ .../src/components/Cascader/Cascader.tsx | 216 ++++++++++++++++-- .../components/Forms/Select/ButtonSelect.tsx | 6 +- .../components/Forms/Select/SelectBase.tsx | 9 + .../src/components/UnitPicker/UnitPicker.mdx | 6 + .../UnitPicker/UnitPicker.story.tsx | 16 ++ packages/grafana-ui/src/components/index.scss | 2 +- packages/grafana-ui/src/components/index.ts | 1 + .../components/InfluxLogsQueryField.tsx | 6 +- .../loki/components/LokiQueryFieldForm.tsx | 4 +- .../components/PromQueryField.test.tsx | 6 +- .../prometheus/components/PromQueryField.tsx | 15 +- 17 files changed, 412 insertions(+), 70 deletions(-) create mode 100644 packages/grafana-ui/src/components/ButtonCascader/ButtonCascader.story.tsx create mode 100644 packages/grafana-ui/src/components/ButtonCascader/ButtonCascader.tsx rename packages/grafana-ui/src/components/{Cascader/_Cascader.scss => ButtonCascader/_ButtonCascader.scss} (100%) create mode 100644 packages/grafana-ui/src/components/Cascader/Cascader.mdx create mode 100644 packages/grafana-ui/src/components/Cascader/Cascader.test.tsx create mode 100644 packages/grafana-ui/src/components/UnitPicker/UnitPicker.mdx create mode 100644 packages/grafana-ui/src/components/UnitPicker/UnitPicker.story.tsx diff --git a/packages/grafana-ui/src/components/ButtonCascader/ButtonCascader.story.tsx b/packages/grafana-ui/src/components/ButtonCascader/ButtonCascader.story.tsx new file mode 100644 index 00000000000..3f182678694 --- /dev/null +++ b/packages/grafana-ui/src/components/ButtonCascader/ButtonCascader.story.tsx @@ -0,0 +1,33 @@ +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 ; +}; diff --git a/packages/grafana-ui/src/components/ButtonCascader/ButtonCascader.tsx b/packages/grafana-ui/src/components/ButtonCascader/ButtonCascader.tsx new file mode 100644 index 00000000000..d99460e5125 --- /dev/null +++ b/packages/grafana-ui/src/components/ButtonCascader/ButtonCascader.tsx @@ -0,0 +1,27 @@ +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 = props => ( + + + +); diff --git a/packages/grafana-ui/src/components/Cascader/_Cascader.scss b/packages/grafana-ui/src/components/ButtonCascader/_ButtonCascader.scss similarity index 100% rename from packages/grafana-ui/src/components/Cascader/_Cascader.scss rename to packages/grafana-ui/src/components/ButtonCascader/_ButtonCascader.scss diff --git a/packages/grafana-ui/src/components/Cascader/Cascader.mdx b/packages/grafana-ui/src/components/Cascader/Cascader.mdx new file mode 100644 index 00000000000..3410735ebbf --- /dev/null +++ b/packages/grafana-ui/src/components/Cascader/Cascader.mdx @@ -0,0 +1,8 @@ +import { Meta, Story, Preview, Props } from '@storybook/addon-docs/blocks'; +import { Cascader } from './Cascader'; + +# Cascader with search + + + + \ No newline at end of file diff --git a/packages/grafana-ui/src/components/Cascader/Cascader.story.tsx b/packages/grafana-ui/src/components/Cascader/Cascader.story.tsx index 4b171d7dd2b..7b83588f63b 100644 --- a/packages/grafana-ui/src/components/Cascader/Cascader.story.tsx +++ b/packages/grafana-ui/src/components/Cascader/Cascader.story.tsx @@ -1,32 +1,49 @@ -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); +const options = [ + { + label: 'First', + value: '1', + items: [ + { + label: 'Second', + value: '2', + }, + { + label: 'Third', + value: '3', + }, + { + label: 'Fourth', + value: '4', + }, + ], + }, + { + label: 'FirstFirst', + value: '5', + }, +]; -CascaderStories.addDecorator(withCenteredStory); - -CascaderStories.add('default', () => { - const { disabled, text, options } = getKnobs(); - return ; -}); +export const simple = () => ( + console.log(val)} /> +); +export const withInitialValue = () => ( + console.log(val)} /> +); diff --git a/packages/grafana-ui/src/components/Cascader/Cascader.test.tsx b/packages/grafana-ui/src/components/Cascader/Cascader.test.tsx new file mode 100644 index 00000000000..f6c3a80c6be --- /dev/null +++ b/packages/grafana-ui/src/components/Cascader/Cascader.test.tsx @@ -0,0 +1,58 @@ +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( {}} />); + }); + + it('Should convert options to searchable strings', () => { + expect(cascader.state('searchableOptions')).toEqual(flatOptions); + }); +}); diff --git a/packages/grafana-ui/src/components/Cascader/Cascader.tsx b/packages/grafana-ui/src/components/Cascader/Cascader.tsx index a30d69fcc79..51a7c328d39 100644 --- a/packages/grafana-ui/src/components/Cascader/Cascader.tsx +++ b/packages/grafana-ui/src/components/Cascader/Cascader.tsx @@ -1,34 +1,204 @@ import React from 'react'; - +import { Icon } from '../Icon/Icon'; // @ts-ignore import RCCascader from 'rc-cascader'; -export interface CascaderOption { - label: string; - value: string; +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'; - children?: CascaderOption[]; +interface CascaderProps { + separator?: string; + options: CascaderOption[]; + onSelect(val: string): void; + size?: FormInputSize; + initialValue?: string; +} + +interface CascaderState { + isSearching: boolean; + searchableOptions: Array>; + focusCascade: boolean; + //Array for cascade navigation + rcValue: SelectableValue; + activeLabel: string; +} + +export interface CascaderOption { + value: any; + label: string; + 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 const Cascader: React.FC = props => ( - - - -); +export class Cascader extends React.PureComponent { + 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> = []; + 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; + }; + + setInitialValue(searchableOptions: Array>, 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) => { + 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) => { + 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 ( +
+ {isSearching ? ( + {}} + size={size || 'md'} + suffix={focusCascade ? : } + /> +
+ + )} + + ); + } +} diff --git a/packages/grafana-ui/src/components/Forms/Select/ButtonSelect.tsx b/packages/grafana-ui/src/components/Forms/Select/ButtonSelect.tsx index 91ef02ca544..01b772cc1bf 100644 --- a/packages/grafana-ui/src/components/Forms/Select/ButtonSelect.tsx +++ b/packages/grafana-ui/src/components/Forms/Select/ButtonSelect.tsx @@ -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({ return ( { + renderControl={React.forwardRef>(({ onBlur, onClick, value, isOpen }, _ref) => { return ( {value ? value.label : placeholder} ); - }} + })} /> ); } diff --git a/packages/grafana-ui/src/components/Forms/Select/SelectBase.tsx b/packages/grafana-ui/src/components/Forms/Select/SelectBase.tsx index f6bac5f9bd0..7ab7de0cc56 100644 --- a/packages/grafana-ui/src/components/Forms/Select/SelectBase.tsx +++ b/packages/grafana-ui/src/components/Forms/Select/SelectBase.tsx @@ -27,10 +27,13 @@ export interface SelectCommonProps { className?: string; options?: Array>; defaultValue?: any; + inputValue?: string; value?: SelectValue; getOptionLabel?: (item: SelectableValue) => string; getOptionValue?: (item: SelectableValue) => string; onChange: (value: SelectableValue) => {} | 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({ value, defaultValue, + inputValue, + onInputChange, options = [], onChange, onBlur, + onKeyDown, onCloseMenu, onOpenMenu, placeholder = 'Choose', @@ -201,6 +207,8 @@ export function SelectBase({ isLoading, menuIsOpen: isOpen, defaultValue, + inputValue, + onInputChange, value: isMulti ? selectedValue : selectedValue[0], getOptionLabel, getOptionValue, @@ -214,6 +222,7 @@ export function SelectBase({ options, onChange, onBlur, + onKeyDown, menuShouldScrollIntoView: false, renderControl, }; diff --git a/packages/grafana-ui/src/components/UnitPicker/UnitPicker.mdx b/packages/grafana-ui/src/components/UnitPicker/UnitPicker.mdx new file mode 100644 index 00000000000..77a294393fa --- /dev/null +++ b/packages/grafana-ui/src/components/UnitPicker/UnitPicker.mdx @@ -0,0 +1,6 @@ +import { Meta, Story, Preview, Props } from '@storybook/addon-docs/blocks'; +import { UnitPicker } from './UnitPicker'; + +# UnitPicker + + \ No newline at end of file diff --git a/packages/grafana-ui/src/components/UnitPicker/UnitPicker.story.tsx b/packages/grafana-ui/src/components/UnitPicker/UnitPicker.story.tsx new file mode 100644 index 00000000000..4d0966a22ba --- /dev/null +++ b/packages/grafana-ui/src/components/UnitPicker/UnitPicker.story.tsx @@ -0,0 +1,16 @@ +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 = () => console.log(val)} />; diff --git a/packages/grafana-ui/src/components/index.scss b/packages/grafana-ui/src/components/index.scss index b3f50fe7cea..af9d68e4745 100644 --- a/packages/grafana-ui/src/components/index.scss +++ b/packages/grafana-ui/src/components/index.scss @@ -1,5 +1,5 @@ @import 'BarGauge/BarGauge'; -@import 'Cascader/Cascader'; +@import 'ButtonCascader/ButtonCascader'; @import 'ColorPicker/ColorPicker'; @import 'CustomScrollbar/CustomScrollbar'; @import 'Drawer/Drawer'; diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 0bc873c90b9..7140e883dd2 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -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 diff --git a/public/app/plugins/datasource/influxdb/components/InfluxLogsQueryField.tsx b/public/app/plugins/datasource/influxdb/components/InfluxLogsQueryField.tsx index b242ab1c4b7..07e85bdc499 100644 --- a/public/app/plugins/datasource/influxdb/components/InfluxLogsQueryField.tsx +++ b/public/app/plugins/datasource/influxdb/components/InfluxLogsQueryField.tsx @@ -1,6 +1,6 @@ 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 { 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 { return (
-
- { 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:', }, diff --git a/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx b/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx index fc88968f164..4184bcc3cb6 100644 --- a/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx +++ b/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx @@ -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 { 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 ({ 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
-