mirror of https://github.com/grafana/grafana
Elasticsearch: Persist custom value for size option in Terms Bucket Agg (#36194)
* Add custom hook to handle creatable select options * Refactor Terms Settings Editor * Make props of Bucket Aggregation settings editors consistent * Rename hook to something more descriptive * Move render test helper * Add tests * small refactor * Remove useless testpull/35633/head
parent
6f38883583
commit
1490c255f1
@ -1,92 +0,0 @@ |
||||
import { getDefaultTimeRange } from '@grafana/data'; |
||||
import { render, screen } from '@testing-library/react'; |
||||
import userEvent from '@testing-library/user-event'; |
||||
import { ElasticDatasource } from 'app/plugins/datasource/elasticsearch/datasource'; |
||||
import { ElasticsearchQuery } from 'app/plugins/datasource/elasticsearch/types'; |
||||
import React, { ComponentProps, ReactNode } from 'react'; |
||||
import { ElasticsearchProvider } from '../../ElasticsearchQueryContext'; |
||||
import { DateHistogram } from '../aggregations'; |
||||
import { DateHistogramSettingsEditor } from './DateHistogramSettingsEditor'; |
||||
|
||||
const renderWithESProvider = ( |
||||
ui: ReactNode, |
||||
{ |
||||
providerProps: { |
||||
datasource = {} as ElasticDatasource, |
||||
query = { refId: 'A' }, |
||||
onChange = () => void 0, |
||||
onRunQuery = () => void 0, |
||||
range = getDefaultTimeRange(), |
||||
} = {}, |
||||
...renderOptions |
||||
}: { providerProps?: Partial<Omit<ComponentProps<typeof ElasticsearchProvider>, 'children'>> } & Parameters< |
||||
typeof render |
||||
>[1] |
||||
) => { |
||||
return render( |
||||
<ElasticsearchProvider |
||||
query={query} |
||||
onChange={onChange} |
||||
datasource={datasource} |
||||
onRunQuery={onRunQuery} |
||||
range={range} |
||||
> |
||||
{ui} |
||||
</ElasticsearchProvider>, |
||||
renderOptions |
||||
); |
||||
}; |
||||
|
||||
describe('DateHistogram Settings Editor', () => { |
||||
describe('Custom options for interval', () => { |
||||
it('Allows users to create and select case sensitive custom options', () => { |
||||
const bucketAgg: DateHistogram = { |
||||
id: '1', |
||||
type: 'date_histogram', |
||||
settings: { |
||||
interval: 'auto', |
||||
}, |
||||
}; |
||||
|
||||
const query: ElasticsearchQuery = { |
||||
refId: 'A', |
||||
bucketAggs: [bucketAgg], |
||||
metrics: [{ id: '2', type: 'count' }], |
||||
query: '', |
||||
}; |
||||
|
||||
const onChange = jest.fn(); |
||||
|
||||
renderWithESProvider(<DateHistogramSettingsEditor bucketAgg={bucketAgg} />, { |
||||
providerProps: { query, onChange }, |
||||
}); |
||||
|
||||
const intervalInput = screen.getByLabelText('Interval') as HTMLInputElement; |
||||
|
||||
expect(intervalInput).toBeInTheDocument(); |
||||
expect(screen.getByText('auto')).toBeInTheDocument(); |
||||
|
||||
// we open the menu
|
||||
userEvent.click(intervalInput); |
||||
|
||||
// default options don't have 1M but 1m
|
||||
expect(screen.queryByText('1M')).not.toBeInTheDocument(); |
||||
expect(screen.getByText('1m')).toBeInTheDocument(); |
||||
|
||||
// we type in the input 1M, which should prompt an option creation
|
||||
userEvent.type(intervalInput, '1M'); |
||||
const creatableOption = screen.getByLabelText('Select option'); |
||||
expect(creatableOption).toHaveTextContent('Create: 1M'); |
||||
|
||||
// we click on the creatable option to trigger its creation
|
||||
userEvent.click(creatableOption); |
||||
|
||||
expect(onChange).toHaveBeenCalled(); |
||||
|
||||
// we open the menu again
|
||||
userEvent.click(intervalInput); |
||||
// the created option should be available
|
||||
expect(screen.getByText('1M')).toBeInTheDocument(); |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1,69 @@ |
||||
import React from 'react'; |
||||
import { InlineField, Select, Input } from '@grafana/ui'; |
||||
import { Terms } from '../aggregations'; |
||||
import { useDispatch } from '../../../../hooks/useStatelessReducer'; |
||||
import { inlineFieldProps } from '.'; |
||||
import { bucketAggregationConfig, createOrderByOptionsFromMetrics, orderOptions, sizeOptions } from '../utils'; |
||||
import { useCreatableSelectPersistedBehaviour } from '../../../hooks/useCreatableSelectPersistedBehaviour'; |
||||
import { changeBucketAggregationSetting } from '../state/actions'; |
||||
import { useQuery } from '../../ElasticsearchQueryContext'; |
||||
|
||||
interface Props { |
||||
bucketAgg: Terms; |
||||
} |
||||
|
||||
export const TermsSettingsEditor = ({ bucketAgg }: Props) => { |
||||
const { metrics } = useQuery(); |
||||
const orderBy = createOrderByOptionsFromMetrics(metrics); |
||||
|
||||
const dispatch = useDispatch(); |
||||
|
||||
return ( |
||||
<> |
||||
<InlineField label="Order" {...inlineFieldProps}> |
||||
<Select |
||||
onChange={(e) => dispatch(changeBucketAggregationSetting(bucketAgg, 'order', e.value!))} |
||||
options={orderOptions} |
||||
value={bucketAgg.settings?.order || bucketAggregationConfig.terms.defaultSettings?.order} |
||||
/> |
||||
</InlineField> |
||||
|
||||
<InlineField label="Size" {...inlineFieldProps}> |
||||
<Select |
||||
// TODO: isValidNewOption should only allow numbers & template variables
|
||||
{...useCreatableSelectPersistedBehaviour({ |
||||
options: sizeOptions, |
||||
value: bucketAgg.settings?.size || bucketAggregationConfig.terms.defaultSettings?.size, |
||||
onChange(value) { |
||||
dispatch(changeBucketAggregationSetting(bucketAgg, 'size', value)); |
||||
}, |
||||
})} |
||||
/> |
||||
</InlineField> |
||||
|
||||
<InlineField label="Min Doc Count" {...inlineFieldProps}> |
||||
<Input |
||||
onBlur={(e) => dispatch(changeBucketAggregationSetting(bucketAgg, 'min_doc_count', e.target.value!))} |
||||
defaultValue={ |
||||
bucketAgg.settings?.min_doc_count || bucketAggregationConfig.terms.defaultSettings?.min_doc_count |
||||
} |
||||
/> |
||||
</InlineField> |
||||
|
||||
<InlineField label="Order By" {...inlineFieldProps}> |
||||
<Select |
||||
onChange={(e) => dispatch(changeBucketAggregationSetting(bucketAgg, 'orderBy', e.value!))} |
||||
options={orderBy} |
||||
value={bucketAgg.settings?.orderBy || bucketAggregationConfig.terms.defaultSettings?.orderBy} |
||||
/> |
||||
</InlineField> |
||||
|
||||
<InlineField label="Missing" {...inlineFieldProps}> |
||||
<Input |
||||
onBlur={(e) => dispatch(changeBucketAggregationSetting(bucketAgg, 'missing', e.target.value!))} |
||||
defaultValue={bucketAgg.settings?.missing || bucketAggregationConfig.terms.defaultSettings?.missing} |
||||
/> |
||||
</InlineField> |
||||
</> |
||||
); |
||||
}; |
@ -0,0 +1,111 @@ |
||||
import React from 'react'; |
||||
import { render, screen } from '@testing-library/react'; |
||||
import { Select, InlineField } from '@grafana/ui'; |
||||
import { useCreatableSelectPersistedBehaviour } from './useCreatableSelectPersistedBehaviour'; |
||||
import userEvent from '@testing-library/user-event'; |
||||
|
||||
describe('useCreatableSelectPersistedBehaviour', () => { |
||||
it('Should make a Select accept custom values', () => { |
||||
const MyComp = (_: { force?: boolean }) => ( |
||||
<InlineField label="label"> |
||||
<Select |
||||
inputId="select" |
||||
{...useCreatableSelectPersistedBehaviour({ |
||||
options: [{ label: 'Option 1', value: 'Option 1' }], |
||||
onChange() {}, |
||||
})} |
||||
/> |
||||
</InlineField> |
||||
); |
||||
|
||||
const { rerender } = render(<MyComp />); |
||||
|
||||
const input = screen.getByLabelText('label') as HTMLInputElement; |
||||
expect(input).toBeInTheDocument(); |
||||
|
||||
// we open the menu
|
||||
userEvent.click(input); |
||||
|
||||
expect(screen.getByText('Option 1')).toBeInTheDocument(); |
||||
|
||||
// we type in the input 'Option 2', which should prompt an option creation
|
||||
userEvent.type(input, 'Option 2'); |
||||
const creatableOption = screen.getByLabelText('Select option'); |
||||
expect(creatableOption).toHaveTextContent('Create: Option 2'); |
||||
|
||||
// we click on the creatable option to trigger its creation
|
||||
userEvent.click(creatableOption); |
||||
|
||||
// Forcing a rerender
|
||||
rerender(<MyComp force={true} />); |
||||
|
||||
// we open the menu again
|
||||
userEvent.click(input); |
||||
// the created option should be available
|
||||
expect(screen.getByText('Option 2')).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('Should handle onChange properly', () => { |
||||
const onChange = jest.fn(); |
||||
const MyComp = () => ( |
||||
<InlineField label="label"> |
||||
<Select |
||||
inputId="select" |
||||
{...useCreatableSelectPersistedBehaviour({ |
||||
options: [{ label: 'Option 1', value: 'Option 1' }], |
||||
onChange, |
||||
})} |
||||
/> |
||||
</InlineField> |
||||
); |
||||
|
||||
render(<MyComp />); |
||||
|
||||
const input = screen.getByLabelText('label') as HTMLInputElement; |
||||
expect(input).toBeInTheDocument(); |
||||
|
||||
// we open the menu
|
||||
userEvent.click(input); |
||||
|
||||
const option1 = screen.getByText('Option 1'); |
||||
expect(option1).toBeInTheDocument(); |
||||
|
||||
// Should call onChange when selecting an already existing option
|
||||
userEvent.click(option1); |
||||
expect(onChange).toHaveBeenCalledWith('Option 1'); |
||||
|
||||
userEvent.click(input); |
||||
|
||||
// we type in the input 'Option 2', which should prompt an option creation
|
||||
userEvent.type(input, 'Option 2'); |
||||
userEvent.click(screen.getByLabelText('Select option')); |
||||
|
||||
expect(onChange).toHaveBeenCalledWith('Option 2'); |
||||
}); |
||||
|
||||
it('Should create an option for value if value is not in options', () => { |
||||
const MyComp = (_: { force?: boolean }) => ( |
||||
<InlineField label="label"> |
||||
<Select |
||||
inputId="select" |
||||
{...useCreatableSelectPersistedBehaviour({ |
||||
options: [{ label: 'Option 1', value: 'Option 1' }], |
||||
value: 'Option 2', |
||||
onChange() {}, |
||||
})} |
||||
/> |
||||
</InlineField> |
||||
); |
||||
|
||||
render(<MyComp />); |
||||
|
||||
const input = screen.getByLabelText('label') as HTMLInputElement; |
||||
expect(input).toBeInTheDocument(); |
||||
|
||||
// we open the menu
|
||||
userEvent.click(input); |
||||
|
||||
// we expect 2 elemnts having "Option 2": the input itself and the option.
|
||||
expect(screen.getAllByText('Option 2')).toHaveLength(2); |
||||
}); |
||||
}); |
@ -0,0 +1,52 @@ |
||||
import { SelectableValue } from '@grafana/data'; |
||||
import { Select } from '@grafana/ui'; |
||||
import { ComponentProps, useState } from 'react'; |
||||
|
||||
const hasValue = <T extends SelectableValue>(searchValue: T['value']) => ({ value }: T) => value === searchValue; |
||||
|
||||
const getInitialState = <T extends SelectableValue>(initialOptions: T[], initialValue?: T['value']): T[] => { |
||||
if (initialValue === undefined || initialOptions.some(hasValue(initialValue))) { |
||||
return initialOptions; |
||||
} |
||||
|
||||
return initialOptions.concat({ |
||||
value: initialValue, |
||||
label: initialValue, |
||||
} as T); |
||||
}; |
||||
|
||||
interface Params<T extends SelectableValue> { |
||||
options: T[]; |
||||
value?: T['value']; |
||||
onChange: (value: T['value']) => void; |
||||
} |
||||
|
||||
/** |
||||
* Creates the Props needed by Select to handle custom values and handles custom value creation |
||||
* and the initial value when it is not present in the option array. |
||||
*/ |
||||
export const useCreatableSelectPersistedBehaviour = <T extends SelectableValue>({ |
||||
options: initialOptions, |
||||
value, |
||||
onChange, |
||||
}: Params<T>): Pick< |
||||
ComponentProps<typeof Select>, |
||||
'onChange' | 'onCreateOption' | 'options' | 'allowCustomValue' | 'value' |
||||
> => { |
||||
const [options, setOptions] = useState<T[]>(getInitialState(initialOptions, value)); |
||||
|
||||
const addOption = (newValue: T['value']) => setOptions([...options, { value: newValue, label: newValue } as T]); |
||||
|
||||
return { |
||||
onCreateOption: (value) => { |
||||
addOption(value); |
||||
onChange(value); |
||||
}, |
||||
onChange: (e) => { |
||||
onChange(e.value); |
||||
}, |
||||
allowCustomValue: true, |
||||
options, |
||||
value, |
||||
}; |
||||
}; |
@ -0,0 +1,34 @@ |
||||
import React, { ComponentProps, ReactNode } from 'react'; |
||||
import { render } from '@testing-library/react'; |
||||
import { getDefaultTimeRange } from '@grafana/data'; |
||||
import { ElasticDatasource } from '../datasource'; |
||||
import { ElasticsearchProvider } from '../components/QueryEditor/ElasticsearchQueryContext'; |
||||
|
||||
export const renderWithESProvider = ( |
||||
ui: ReactNode, |
||||
{ |
||||
providerProps: { |
||||
datasource = {} as ElasticDatasource, |
||||
query = { refId: 'A' }, |
||||
onChange = () => void 0, |
||||
onRunQuery = () => void 0, |
||||
range = getDefaultTimeRange(), |
||||
} = {}, |
||||
...renderOptions |
||||
}: { providerProps?: Partial<Omit<ComponentProps<typeof ElasticsearchProvider>, 'children'>> } & Parameters< |
||||
typeof render |
||||
>[1] |
||||
) => { |
||||
return render( |
||||
<ElasticsearchProvider |
||||
query={query} |
||||
onChange={onChange} |
||||
datasource={datasource} |
||||
onRunQuery={onRunQuery} |
||||
range={range} |
||||
> |
||||
{ui} |
||||
</ElasticsearchProvider>, |
||||
renderOptions |
||||
); |
||||
}; |
Loading…
Reference in new issue