[Lens] Combined histogram/range aggregation for numbers (#76121)

Co-authored-by: Caroline Horn <549577+cchaos@users.noreply.github.com>
Co-authored-by: Wylie Conlon <wylieconlon@gmail.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: Marta Bondyra <marta.bondyra@elastic.co>
Co-authored-by: cchaos <caroline.horn@elastic.co>
This commit is contained in:
Marco Liberati 2020-09-23 10:23:55 +02:00 committed by GitHub
parent 0d09cea436
commit 0f8043ca8d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 1374 additions and 33 deletions

View file

@ -75,6 +75,10 @@ export function BucketNestingEditor({
defaultMessage: 'Top values for each {field}',
values: { field: fieldName },
}),
range: i18n.translate('xpack.lens.indexPattern.groupingOverallRanges', {
defaultMessage: 'Top values for each {field}',
values: { field: fieldName },
}),
};
const bottomLevelCopy: Record<string, string> = {
@ -90,6 +94,10 @@ export function BucketNestingEditor({
defaultMessage: 'Overall top {target}',
values: { target: target.fieldName },
}),
range: i18n.translate('xpack.lens.indexPattern.groupingSecondRanges', {
defaultMessage: 'Overall top {target}',
values: { target: target.fieldName },
}),
};
return (

View file

@ -332,7 +332,6 @@ export function DimensionEditor(props: DimensionEditorProps) {
{!incompatibleSelectedOperationType && ParamEditor && (
<>
<EuiSpacer size="s" />
<ParamEditor
state={state}
setState={setState}

View file

@ -9,7 +9,6 @@ import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiForm,
EuiFormRow,
EuiSwitch,
EuiSwitchEvent,
@ -42,7 +41,7 @@ export const dateHistogramOperation: OperationDefinition<DateHistogramIndexPatte
displayName: i18n.translate('xpack.lens.indexPattern.dateHistogram', {
defaultMessage: 'Date histogram',
}),
priority: 3, // Higher than any metric
priority: 5, // Highest priority level used
getPossibleOperationForField: ({ aggregationRestrictions, aggregatable, type }) => {
if (
type === 'date' &&
@ -180,7 +179,7 @@ export const dateHistogramOperation: OperationDefinition<DateHistogramIndexPatte
};
return (
<EuiForm>
<>
{!intervalIsRestricted && (
<EuiFormRow display="rowCompressed" hasChildLabel={false}>
<EuiSwitch
@ -314,7 +313,7 @@ export const dateHistogramOperation: OperationDefinition<DateHistogramIndexPatte
)}
</EuiFormRow>
)}
</EuiForm>
</>
);
},
};

View file

@ -226,6 +226,7 @@ export const FilterList = ({
removeTitle={i18n.translate('xpack.lens.indexPattern.filters.removeFilter', {
defaultMessage: 'Remove a filter',
})}
isNotRemovable={localFilters.length === 1}
>
<FilterPopover
data-test-subj="indexPattern-filters-existingFilterContainer"

View file

@ -26,6 +26,7 @@ import { BaseIndexPatternColumn } from './column_types';
import { IndexPatternPrivateState, IndexPattern, IndexPatternField } from '../../types';
import { DateRange } from '../../../../common';
import { DataPublicPluginStart } from '../../../../../../../src/plugins/data/public';
import { RangeIndexPatternColumn, rangeOperation } from './ranges';
// List of all operation definitions registered to this data source.
// If you want to implement a new operation, add the definition to this array and
@ -40,6 +41,7 @@ const internalOperationDefinitions = [
cardinalityOperation,
sumOperation,
countOperation,
rangeOperation,
];
/**
@ -49,6 +51,7 @@ const internalOperationDefinitions = [
*/
export type IndexPatternColumn =
| FiltersIndexPatternColumn
| RangeIndexPatternColumn
| TermsIndexPatternColumn
| DateHistogramIndexPatternColumn
| MinIndexPatternColumn
@ -59,6 +62,7 @@ export type IndexPatternColumn =
| CountIndexPatternColumn;
export { termsOperation } from './terms';
export { rangeOperation } from './ranges';
export { filtersOperation } from './filters';
export { dateHistogramOperation } from './date_histogram';
export { minOperation, averageOperation, sumOperation, maxOperation } from './metrics';

View file

@ -0,0 +1,6 @@
.lnsRangesOperation__popoverButton {
@include euiTextBreakWord;
@include euiFontSizeS;
min-height: $euiSizeXL;
width: 100%;
}

View file

@ -0,0 +1,296 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import './advanced_editor.scss';
import React, { useState, MouseEventHandler } from 'react';
import { i18n } from '@kbn/i18n';
import { useDebounce } from 'react-use';
import {
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiIcon,
EuiFieldNumber,
EuiLink,
EuiText,
EuiPopover,
EuiToolTip,
htmlIdGenerator,
} from '@elastic/eui';
import { keys } from '@elastic/eui';
import { IFieldFormat } from '../../../../../../../../src/plugins/data/common';
import { RangeTypeLens, isValidRange, isValidNumber } from './ranges';
import { FROM_PLACEHOLDER, TO_PLACEHOLDER, TYPING_DEBOUNCE_TIME } from './constants';
import { NewBucketButton, DragDropBuckets, DraggableBucketContainer } from '../shared_components';
const generateId = htmlIdGenerator();
type LocalRangeType = RangeTypeLens & { id: string };
const getBetterLabel = (range: RangeTypeLens, formatter: IFieldFormat) =>
range.label ||
formatter.convert({
gte: isValidNumber(range.from) ? range.from : FROM_PLACEHOLDER,
lt: isValidNumber(range.to) ? range.to : TO_PLACEHOLDER,
});
export const RangePopover = ({
range,
setRange,
Button,
isOpenByCreation,
setIsOpenByCreation,
}: {
range: LocalRangeType;
setRange: (newRange: LocalRangeType) => void;
Button: React.FunctionComponent<{ onClick: MouseEventHandler }>;
isOpenByCreation: boolean;
setIsOpenByCreation: (open: boolean) => void;
formatter: IFieldFormat;
}) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [tempRange, setTempRange] = useState(range);
const saveRangeAndReset = (newRange: LocalRangeType, resetRange = false) => {
if (resetRange) {
// reset the temporary range for later use
setTempRange(range);
}
// send the range back to the main state
setRange(newRange);
};
const { from, to } = tempRange;
const lteAppendLabel = i18n.translate('xpack.lens.indexPattern.ranges.lessThanOrEqualAppend', {
defaultMessage: '\u2264',
});
const lteTooltipContent = i18n.translate(
'xpack.lens.indexPattern.ranges.lessThanOrEqualTooltip',
{
defaultMessage: 'Less than or equal to',
}
);
const ltPrependLabel = i18n.translate('xpack.lens.indexPattern.ranges.lessThanPrepend', {
defaultMessage: '\u003c',
});
const ltTooltipContent = i18n.translate('xpack.lens.indexPattern.ranges.lessThanTooltip', {
defaultMessage: 'Less than',
});
const onSubmit = () => {
setIsPopoverOpen(false);
setIsOpenByCreation(false);
saveRangeAndReset(tempRange, true);
};
return (
<EuiPopover
display="block"
ownFocus
isOpen={isOpenByCreation || isPopoverOpen}
closePopover={onSubmit}
button={
<Button
onClick={() => {
setIsPopoverOpen((isOpen) => !isOpen);
setIsOpenByCreation(false);
}}
/>
}
data-test-subj="indexPattern-ranges-popover"
>
<EuiFormRow>
<EuiFlexGroup gutterSize="s" responsive={false} alignItems="center">
<EuiFlexItem>
<EuiFieldNumber
value={isValidNumber(from) ? Number(from) : ''}
onChange={({ target }) => {
const newRange = {
...tempRange,
from: target.value !== '' ? Number(target.value) : -Infinity,
};
setTempRange(newRange);
saveRangeAndReset(newRange);
}}
append={
<EuiToolTip content={lteTooltipContent}>
<EuiText size="s">{lteAppendLabel}</EuiText>
</EuiToolTip>
}
fullWidth
compressed
placeholder={FROM_PLACEHOLDER}
isInvalid={!isValidRange(tempRange)}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiIcon type="sortRight" color="subdued" />
</EuiFlexItem>
<EuiFlexItem>
<EuiFieldNumber
value={isFinite(to) ? Number(to) : ''}
onChange={({ target }) => {
const newRange = {
...tempRange,
to: target.value !== '' ? Number(target.value) : -Infinity,
};
setTempRange(newRange);
saveRangeAndReset(newRange);
}}
prepend={
<EuiToolTip content={ltTooltipContent}>
<EuiText size="s">{ltPrependLabel}</EuiText>
</EuiToolTip>
}
fullWidth
compressed
placeholder={TO_PLACEHOLDER}
isInvalid={!isValidRange(tempRange)}
onKeyDown={({ key }: React.KeyboardEvent<HTMLInputElement>) => {
if (keys.ENTER === key && onSubmit) {
onSubmit();
}
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
</EuiPopover>
);
};
export const AdvancedRangeEditor = ({
ranges,
setRanges,
onToggleEditor,
formatter,
}: {
ranges: RangeTypeLens[];
setRanges: (newRanges: RangeTypeLens[]) => void;
onToggleEditor: () => void;
formatter: IFieldFormat;
}) => {
// use a local state to store ids with range objects
const [localRanges, setLocalRanges] = useState<LocalRangeType[]>(() =>
ranges.map((range) => ({ ...range, id: generateId() }))
);
// we need to force the open state of the popover from the outside in some scenarios
// so we need an extra state here
const [isOpenByCreation, setIsOpenByCreation] = useState(false);
const lastIndex = localRanges.length - 1;
// Update locally all the time, but bounce the parents prop function
// to aviod too many requests
useDebounce(
() => {
setRanges(localRanges.map(({ id, ...rest }) => ({ ...rest })));
},
TYPING_DEBOUNCE_TIME,
[localRanges]
);
const addNewRange = () => {
setLocalRanges([
...localRanges,
{
id: generateId(),
from: localRanges[localRanges.length - 1].to,
to: Infinity,
label: '',
},
]);
};
return (
<EuiFormRow
label={i18n.translate('xpack.lens.indexPattern.ranges.intervals', {
defaultMessage: 'Intervals',
})}
labelAppend={
<EuiText size="xs">
<EuiLink color="danger" onClick={onToggleEditor}>
<EuiIcon size="s" type="cross" color="danger" />{' '}
{i18n.translate('xpack.lens.indexPattern.ranges.customIntervalsRemoval', {
defaultMessage: 'Remove custom intervals',
})}
</EuiLink>
</EuiText>
}
>
<>
<DragDropBuckets
onDragEnd={setLocalRanges}
onDragStart={() => setIsOpenByCreation(false)}
droppableId="RANGES_DROPPABLE_AREA"
items={localRanges}
>
{localRanges.map((range: LocalRangeType, idx: number) => (
<DraggableBucketContainer
key={range.id}
idx={idx}
id={range.id}
isInvalid={!isValidRange(range)}
invalidMessage={i18n.translate('xpack.lens.indexPattern.range.isInvalid', {
defaultMessage: 'This range is invalid',
})}
onRemoveClick={() => {
const newRanges = localRanges.filter((_, i) => i !== idx);
setLocalRanges(newRanges);
}}
removeTitle={i18n.translate('xpack.lens.indexPattern.ranges.deleteRange', {
defaultMessage: 'Delete range',
})}
isNotRemovable={localRanges.length === 1}
>
<RangePopover
range={range}
isOpenByCreation={idx === lastIndex && isOpenByCreation}
setIsOpenByCreation={setIsOpenByCreation}
setRange={(newRange: LocalRangeType) => {
const newRanges = [...localRanges];
if (newRange.id === newRanges[idx].id) {
newRanges[idx] = newRange;
} else {
newRanges.push(newRange);
}
setLocalRanges(newRanges);
}}
formatter={formatter}
Button={({ onClick }: { onClick: MouseEventHandler }) => (
<EuiLink
color="text"
onClick={onClick}
className="lnsRangesOperation__popoverButton"
data-test-subj="indexPattern-ranges-popover-trigger"
>
<EuiText
size="s"
textAlign="left"
color={isValidRange(range) ? 'default' : 'danger'}
>
{getBetterLabel(range, formatter)}
</EuiText>
</EuiLink>
)}
/>
</DraggableBucketContainer>
))}
</DragDropBuckets>
<NewBucketButton
onClick={() => {
addNewRange();
setIsOpenByCreation(true);
}}
label={i18n.translate('xpack.lens.indexPattern.ranges.addInterval', {
defaultMessage: 'Add interval',
})}
/>
</>
</EuiFormRow>
);
};

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export const TYPING_DEBOUNCE_TIME = 256;
// Taken from the Visualize editor
export const FROM_PLACEHOLDER = '\u2212\u221E';
export const TO_PLACEHOLDER = '+\u221E';
export const DEFAULT_INTERVAL = 1000;
export const AUTO_BARS = 'auto';
export const MIN_HISTOGRAM_BARS = 1;
export const SLICES = 6;
export const MODES = {
Range: 'range',
Histogram: 'histogram',
} as const;

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export * from './ranges';

View file

@ -0,0 +1,175 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { useDebounce } from 'react-use';
import {
EuiButtonEmpty,
EuiFormRow,
EuiRange,
EuiFlexItem,
EuiFlexGroup,
EuiButtonIcon,
EuiToolTip,
} from '@elastic/eui';
import { IFieldFormat } from 'src/plugins/data/public';
import { RangeColumnParams, UpdateParamsFnType, MODES_TYPES } from './ranges';
import { AdvancedRangeEditor } from './advanced_editor';
import { TYPING_DEBOUNCE_TIME, MODES, MIN_HISTOGRAM_BARS } from './constants';
const BaseRangeEditor = ({
maxBars,
step,
maxHistogramBars,
onToggleEditor,
onMaxBarsChange,
}: {
maxBars: number;
step: number;
maxHistogramBars: number;
onToggleEditor: () => void;
onMaxBarsChange: (newMaxBars: number) => void;
}) => {
const [maxBarsValue, setMaxBarsValue] = useState(String(maxBars));
useDebounce(
() => {
onMaxBarsChange(Number(maxBarsValue));
},
TYPING_DEBOUNCE_TIME,
[maxBarsValue]
);
const granularityLabel = i18n.translate('xpack.lens.indexPattern.ranges.granularity', {
defaultMessage: 'Granularity',
});
const decreaseButtonLabel = i18n.translate('xpack.lens.indexPattern.ranges.decreaseButtonLabel', {
defaultMessage: 'Decrease granularity',
});
const increaseButtonLabel = i18n.translate('xpack.lens.indexPattern.ranges.increaseButtonLabel', {
defaultMessage: 'Increase granularity',
});
return (
<>
<EuiFormRow
label={granularityLabel}
data-test-subj="indexPattern-ranges-section-label"
labelType="legend"
fullWidth
display="rowCompressed"
>
<EuiFlexGroup alignItems="center" gutterSize="xs" responsive={false}>
<EuiFlexItem grow={false}>
<EuiToolTip content={decreaseButtonLabel} delay="long">
<EuiButtonIcon
iconType="minusInCircle"
color="text"
data-test-subj="lns-indexPattern-range-maxBars-minus"
onClick={() =>
setMaxBarsValue('' + Math.max(Number(maxBarsValue) - step, MIN_HISTOGRAM_BARS))
}
aria-label={decreaseButtonLabel}
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem>
<EuiRange
compressed
fullWidth
aria-label={granularityLabel}
data-test-subj="lns-indexPattern-range-maxBars-field"
min={MIN_HISTOGRAM_BARS}
max={maxHistogramBars}
step={step}
value={maxBarsValue}
onChange={({ currentTarget }) => setMaxBarsValue(currentTarget.value)}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip content={decreaseButtonLabel} delay="long">
<EuiButtonIcon
iconType="plusInCircle"
color="text"
data-test-subj="lns-indexPattern-range-maxBars-plus"
onClick={() =>
setMaxBarsValue('' + Math.min(Number(maxBarsValue) + step, maxHistogramBars))
}
aria-label={increaseButtonLabel}
/>
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
<EuiButtonEmpty size="xs" iconType="controlsHorizontal" onClick={() => onToggleEditor()}>
{i18n.translate('xpack.lens.indexPattern.ranges.customIntervalsToggle', {
defaultMessage: 'Create custom intervals',
})}
</EuiButtonEmpty>
</>
);
};
export const RangeEditor = ({
setParam,
params,
maxHistogramBars,
maxBars,
granularityStep,
onChangeMode,
rangeFormatter,
}: {
params: RangeColumnParams;
maxHistogramBars: number;
maxBars: number;
granularityStep: number;
setParam: UpdateParamsFnType;
onChangeMode: (mode: MODES_TYPES) => void;
rangeFormatter: IFieldFormat;
}) => {
const [isAdvancedEditor, toggleAdvancedEditor] = useState(params.type === MODES.Range);
// if the maxBars in the params is set to auto refresh it with the default value
// only on bootstrap
useEffect(() => {
if (params.maxBars !== maxBars) {
setParam('maxBars', maxBars);
}
}, [maxBars, params.maxBars, setParam]);
if (isAdvancedEditor) {
return (
<AdvancedRangeEditor
ranges={params.ranges}
setRanges={(ranges) => {
setParam('ranges', ranges);
}}
onToggleEditor={() => {
onChangeMode(MODES.Histogram);
toggleAdvancedEditor(false);
}}
formatter={rangeFormatter}
/>
);
}
return (
<BaseRangeEditor
maxBars={maxBars}
step={granularityStep}
maxHistogramBars={maxHistogramBars}
onMaxBarsChange={(newMaxBars: number) => {
setParam('maxBars', newMaxBars);
}}
onToggleEditor={() => {
onChangeMode(MODES.Range);
toggleAdvancedEditor(true);
}}
/>
);
};

View file

@ -0,0 +1,555 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { mount } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { EuiFieldNumber, EuiRange, EuiButtonEmpty, EuiLink } from '@elastic/eui';
import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup } from 'kibana/public';
import { IStorageWrapper } from 'src/plugins/kibana_utils/public';
import { IndexPatternPrivateState, IndexPattern } from '../../../types';
import { dataPluginMock } from '../../../../../../../../src/plugins/data/public/mocks';
import { rangeOperation } from '../index';
import { RangeIndexPatternColumn } from './ranges';
import {
MODES,
DEFAULT_INTERVAL,
TYPING_DEBOUNCE_TIME,
MIN_HISTOGRAM_BARS,
SLICES,
} from './constants';
import { RangePopover } from './advanced_editor';
import { DragDropBuckets } from '../shared_components';
const dataPluginMockValue = dataPluginMock.createStartContract();
// need to overwrite the formatter field first
dataPluginMockValue.fieldFormats.deserialize = jest.fn().mockImplementation(() => {
return { convert: ({ gte, lt }: { gte: string; lt: string }) => `${gte} - ${lt}` };
});
type ReactMouseEvent = React.MouseEvent<HTMLAnchorElement, MouseEvent> &
React.MouseEvent<HTMLButtonElement, MouseEvent>;
const defaultOptions = {
storage: {} as IStorageWrapper,
// need this for MAX_HISTOGRAM value
uiSettings: ({
get: () => 100,
} as unknown) as IUiSettingsClient,
savedObjectsClient: {} as SavedObjectsClientContract,
dateRange: {
fromDate: 'now-1y',
toDate: 'now',
},
data: dataPluginMockValue,
http: {} as HttpSetup,
};
describe('ranges', () => {
let state: IndexPatternPrivateState;
const InlineOptions = rangeOperation.paramEditor!;
const sourceField = 'MyField';
const MAX_HISTOGRAM_VALUE = 100;
const GRANULARITY_DEFAULT_VALUE = (MAX_HISTOGRAM_VALUE - MIN_HISTOGRAM_BARS) / 2;
const GRANULARITY_STEP = (MAX_HISTOGRAM_VALUE - MIN_HISTOGRAM_BARS) / SLICES;
function setToHistogramMode() {
const column = state.layers.first.columns.col1 as RangeIndexPatternColumn;
column.dataType = 'number';
column.scale = 'interval';
column.params.type = MODES.Histogram;
}
function setToRangeMode() {
const column = state.layers.first.columns.col1 as RangeIndexPatternColumn;
column.dataType = 'string';
column.scale = 'ordinal';
column.params.type = MODES.Range;
}
function getDefaultState(): IndexPatternPrivateState {
return {
indexPatternRefs: [],
indexPatterns: {},
existingFields: {},
currentIndexPatternId: '1',
isFirstExistenceFetch: false,
layers: {
first: {
indexPatternId: '1',
columnOrder: ['col1', 'col2'],
columns: {
// Start with the histogram type
col1: {
label: sourceField,
dataType: 'number',
operationType: 'range',
scale: 'interval',
isBucketed: true,
sourceField,
params: {
type: MODES.Histogram,
ranges: [{ from: 0, to: DEFAULT_INTERVAL, label: '' }],
maxBars: 'auto',
},
},
col2: {
label: 'Count',
dataType: 'number',
isBucketed: false,
sourceField: 'Records',
operationType: 'count',
},
},
},
},
};
}
beforeAll(() => {
jest.useFakeTimers();
});
beforeEach(() => {
state = getDefaultState();
});
describe('toEsAggConfig', () => {
afterAll(() => setToHistogramMode());
it('should reflect params correctly', () => {
const esAggsConfig = rangeOperation.toEsAggsConfig(
state.layers.first.columns.col1 as RangeIndexPatternColumn,
'col1',
{} as IndexPattern
);
expect(esAggsConfig).toEqual(
expect.objectContaining({
type: MODES.Histogram,
params: expect.objectContaining({
field: sourceField,
maxBars: null,
}),
})
);
});
it('should reflect the type correctly', () => {
setToRangeMode();
const esAggsConfig = rangeOperation.toEsAggsConfig(
state.layers.first.columns.col1 as RangeIndexPatternColumn,
'col1',
{} as IndexPattern
);
expect(esAggsConfig).toEqual(
expect.objectContaining({
type: MODES.Range,
})
);
});
});
describe('getPossibleOperationForField', () => {
it('should return operation with the right type for number', () => {
expect(
rangeOperation.getPossibleOperationForField({
aggregatable: true,
searchable: true,
name: 'test',
displayName: 'test',
type: 'number',
})
).toEqual({
dataType: 'number',
isBucketed: true,
scale: 'interval',
});
});
it('should not return operation if field type is not number', () => {
expect(
rangeOperation.getPossibleOperationForField({
aggregatable: false,
searchable: true,
name: 'test',
displayName: 'test',
type: 'string',
})
).toEqual(undefined);
});
});
describe('paramEditor', () => {
describe('Modify intervals in basic mode', () => {
beforeEach(() => {
state = getDefaultState();
});
it('should start update the state with the default maxBars value', () => {
const setStateSpy = jest.fn();
mount(
<InlineOptions
{...defaultOptions}
state={state}
setState={setStateSpy}
columnId="col1"
currentColumn={state.layers.first.columns.col1 as RangeIndexPatternColumn}
layerId="first"
/>
);
expect(setStateSpy).toHaveBeenCalledWith({
...state,
layers: {
first: {
...state.layers.first,
columns: {
...state.layers.first.columns,
col1: {
...state.layers.first.columns.col1,
params: {
...state.layers.first.columns.col1.params,
maxBars: GRANULARITY_DEFAULT_VALUE,
},
},
},
},
},
});
});
it('should update state when changing Max bars number', () => {
const setStateSpy = jest.fn();
const instance = mount(
<InlineOptions
{...defaultOptions}
state={state}
setState={setStateSpy}
columnId="col1"
currentColumn={state.layers.first.columns.col1 as RangeIndexPatternColumn}
layerId="first"
/>
);
act(() => {
instance.find(EuiRange).prop('onChange')!(
{
currentTarget: {
value: '' + MAX_HISTOGRAM_VALUE,
},
} as React.ChangeEvent<HTMLInputElement>,
true
);
jest.advanceTimersByTime(TYPING_DEBOUNCE_TIME * 4);
expect(setStateSpy).toHaveBeenCalledWith({
...state,
layers: {
first: {
...state.layers.first,
columns: {
...state.layers.first.columns,
col1: {
...state.layers.first.columns.col1,
params: {
...state.layers.first.columns.col1.params,
maxBars: MAX_HISTOGRAM_VALUE,
},
},
},
},
},
});
});
});
it('should update the state using the plus or minus buttons by the step amount', () => {
const setStateSpy = jest.fn();
const instance = mount(
<InlineOptions
{...defaultOptions}
state={state}
setState={setStateSpy}
columnId="col1"
currentColumn={state.layers.first.columns.col1 as RangeIndexPatternColumn}
layerId="first"
/>
);
act(() => {
// minus button
instance
.find('[data-test-subj="lns-indexPattern-range-maxBars-minus"]')
.find('button')
.prop('onClick')!({} as ReactMouseEvent);
jest.advanceTimersByTime(TYPING_DEBOUNCE_TIME * 4);
expect(setStateSpy).toHaveBeenCalledWith({
...state,
layers: {
first: {
...state.layers.first,
columns: {
...state.layers.first.columns,
col1: {
...state.layers.first.columns.col1,
params: {
...state.layers.first.columns.col1.params,
maxBars: GRANULARITY_DEFAULT_VALUE - GRANULARITY_STEP,
},
},
},
},
},
});
// plus button
instance
.find('[data-test-subj="lns-indexPattern-range-maxBars-plus"]')
.find('button')
.prop('onClick')!({} as ReactMouseEvent);
jest.advanceTimersByTime(TYPING_DEBOUNCE_TIME * 4);
expect(setStateSpy).toHaveBeenCalledWith({
...state,
layers: {
first: {
...state.layers.first,
columns: {
...state.layers.first.columns,
col1: {
...state.layers.first.columns.col1,
params: {
...state.layers.first.columns.col1.params,
maxBars: GRANULARITY_DEFAULT_VALUE,
},
},
},
},
},
});
});
});
});
describe('Specify range intervals manually', () => {
// @ts-expect-error
window['__react-beautiful-dnd-disable-dev-warnings'] = true; // issue with enzyme & react-beautiful-dnd throwing errors: https://github.com/atlassian/react-beautiful-dnd/issues/1593
beforeEach(() => setToRangeMode());
it('should show one range interval to start with', () => {
const setStateSpy = jest.fn();
const instance = mount(
<InlineOptions
{...defaultOptions}
state={state}
setState={setStateSpy}
columnId="col1"
currentColumn={state.layers.first.columns.col1 as RangeIndexPatternColumn}
layerId="first"
/>
);
expect(instance.find(DragDropBuckets).children).toHaveLength(1);
});
it('should add a new range', () => {
const setStateSpy = jest.fn();
const instance = mount(
<InlineOptions
{...defaultOptions}
state={state}
setState={setStateSpy}
columnId="col1"
currentColumn={state.layers.first.columns.col1 as RangeIndexPatternColumn}
layerId="first"
/>
);
// This series of act clojures are made to make it work properly the update flush
act(() => {
instance.find(EuiButtonEmpty).prop('onClick')!({} as ReactMouseEvent);
});
act(() => {
// need another wrapping for this in order to work
instance.update();
expect(instance.find(RangePopover)).toHaveLength(2);
// edit the range and check
instance.find(RangePopover).find(EuiFieldNumber).first().prop('onChange')!({
target: {
value: '50',
},
} as React.ChangeEvent<HTMLInputElement>);
jest.advanceTimersByTime(TYPING_DEBOUNCE_TIME * 4);
expect(setStateSpy).toHaveBeenCalledWith({
...state,
layers: {
first: {
...state.layers.first,
columns: {
...state.layers.first.columns,
col1: {
...state.layers.first.columns.col1,
params: {
...state.layers.first.columns.col1.params,
ranges: [
{ from: 0, to: DEFAULT_INTERVAL, label: '' },
{ from: 50, to: Infinity, label: '' },
],
},
},
},
},
},
});
});
});
it('should open a popover to edit an existing range', () => {
const setStateSpy = jest.fn();
const instance = mount(
<InlineOptions
{...defaultOptions}
state={state}
setState={setStateSpy}
columnId="col1"
currentColumn={state.layers.first.columns.col1 as RangeIndexPatternColumn}
layerId="first"
/>
);
// This series of act clojures are made to make it work properly the update flush
act(() => {
instance.find(RangePopover).find(EuiLink).prop('onClick')!({} as ReactMouseEvent);
});
act(() => {
// need another wrapping for this in order to work
instance.update();
// edit the range "to" field
instance.find(RangePopover).find(EuiFieldNumber).last().prop('onChange')!({
target: {
value: '50',
},
} as React.ChangeEvent<HTMLInputElement>);
jest.advanceTimersByTime(TYPING_DEBOUNCE_TIME * 4);
expect(setStateSpy).toHaveBeenCalledWith({
...state,
layers: {
first: {
...state.layers.first,
columns: {
...state.layers.first.columns,
col1: {
...state.layers.first.columns.col1,
params: {
...state.layers.first.columns.col1.params,
ranges: [{ from: 0, to: 50, label: '' }],
},
},
},
},
},
});
});
});
it('should not accept invalid ranges', () => {
const setStateSpy = jest.fn();
const instance = mount(
<InlineOptions
{...defaultOptions}
state={state}
setState={setStateSpy}
columnId="col1"
currentColumn={state.layers.first.columns.col1 as RangeIndexPatternColumn}
layerId="first"
/>
);
// This series of act clojures are made to make it work properly the update flush
act(() => {
instance.find(RangePopover).find(EuiLink).prop('onClick')!({} as ReactMouseEvent);
});
act(() => {
// need another wrapping for this in order to work
instance.update();
// edit the range "to" field
instance.find(RangePopover).find(EuiFieldNumber).last().prop('onChange')!({
target: {
value: '-1',
},
} as React.ChangeEvent<HTMLInputElement>);
});
act(() => {
instance.update();
// and check
expect(instance.find(RangePopover).find(EuiFieldNumber).last().prop('isInvalid')).toBe(
true
);
});
});
it('should be possible to remove a range if multiple', () => {
const setStateSpy = jest.fn();
// Add an extra range
(state.layers.first.columns.col1 as RangeIndexPatternColumn).params.ranges.push({
from: DEFAULT_INTERVAL,
to: 2 * DEFAULT_INTERVAL,
label: '',
});
const instance = mount(
<InlineOptions
{...defaultOptions}
state={state}
setState={setStateSpy}
columnId="col1"
currentColumn={state.layers.first.columns.col1 as RangeIndexPatternColumn}
layerId="first"
/>
);
expect(instance.find(RangePopover)).toHaveLength(2);
// This series of act closures are made to make it work properly the update flush
act(() => {
instance
.find('[data-test-subj="lns-customBucketContainer-remove"]')
.last()
.prop('onClick')!({} as ReactMouseEvent);
});
act(() => {
// need another wrapping for this in order to work
instance.update();
expect(instance.find(RangePopover)).toHaveLength(1);
});
});
});
});
});

View file

@ -0,0 +1,199 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { UI_SETTINGS } from '../../../../../../../../src/plugins/data/common';
import { Range } from '../../../../../../../../src/plugins/expressions/common/expression_types/index';
import { RangeEditor } from './range_editor';
import { OperationDefinition } from '../index';
import { FieldBasedIndexPatternColumn } from '../column_types';
import { updateColumnParam, changeColumn } from '../../../state_helpers';
import { MODES, AUTO_BARS, DEFAULT_INTERVAL, MIN_HISTOGRAM_BARS, SLICES } from './constants';
type RangeType = Omit<Range, 'type'>;
export type RangeTypeLens = RangeType & { label: string };
export type MODES_TYPES = typeof MODES[keyof typeof MODES];
export interface RangeIndexPatternColumn extends FieldBasedIndexPatternColumn {
operationType: 'range';
params: {
type: MODES_TYPES;
maxBars: typeof AUTO_BARS | number;
ranges: RangeTypeLens[];
};
}
export type RangeColumnParams = RangeIndexPatternColumn['params'];
export type UpdateParamsFnType = <K extends keyof RangeColumnParams>(
paramName: K,
value: RangeColumnParams[K]
) => void;
export const isValidNumber = (value: number | '') =>
value !== '' && !isNaN(value) && isFinite(value);
export const isRangeWithin = (range: RangeTypeLens): boolean => range.from <= range.to;
const isFullRange = ({ from, to }: RangeType) => isValidNumber(from) && isValidNumber(to);
export const isValidRange = (range: RangeTypeLens): boolean => {
if (isFullRange(range)) {
return isRangeWithin(range);
}
return true;
};
function getEsAggsParams({ sourceField, params }: RangeIndexPatternColumn) {
if (params.type === MODES.Range) {
return {
field: sourceField,
ranges: params.ranges.filter(isValidRange).map<Partial<RangeType>>((range) => {
if (isFullRange(range)) {
return { from: range.from, to: range.to };
}
const partialRange: Partial<RangeType> = {};
// be careful with the fields to set on partial ranges
if (isValidNumber(range.from)) {
partialRange.from = range.from;
}
if (isValidNumber(range.to)) {
partialRange.to = range.to;
}
return partialRange;
}),
};
}
return {
field: sourceField,
// fallback to 0 in case of empty string
maxBars: params.maxBars === AUTO_BARS ? null : params.maxBars,
has_extended_bounds: false,
min_doc_count: 0,
extended_bounds: { min: '', max: '' },
};
}
export const rangeOperation: OperationDefinition<RangeIndexPatternColumn> = {
type: 'range',
displayName: i18n.translate('xpack.lens.indexPattern.ranges', {
defaultMessage: 'Ranges',
}),
priority: 4, // Higher than terms, so numbers get histogram
getPossibleOperationForField: ({ aggregationRestrictions, aggregatable, type }) => {
if (
type === 'number' &&
aggregatable &&
(!aggregationRestrictions || aggregationRestrictions.range)
) {
return {
dataType: 'number',
isBucketed: true,
scale: 'interval',
};
}
},
buildColumn({ suggestedPriority, field }) {
return {
label: field.name,
dataType: 'number', // string for Range
operationType: 'range',
suggestedPriority,
sourceField: field.name,
isBucketed: true,
scale: 'interval', // ordinal for Range
params: {
type: MODES.Histogram,
ranges: [{ from: 0, to: DEFAULT_INTERVAL, label: '' }],
maxBars: AUTO_BARS,
},
};
},
isTransferable: (column, newIndexPattern) => {
const newField = newIndexPattern.fields.find((field) => field.name === column.sourceField);
return Boolean(
newField &&
newField.type === 'number' &&
newField.aggregatable &&
(!newField.aggregationRestrictions || newField.aggregationRestrictions.range)
);
},
onFieldChange: (oldColumn, indexPattern, field) => {
return {
...oldColumn,
label: field.name,
sourceField: field.name,
};
},
toEsAggsConfig: (column, columnId) => {
const params = getEsAggsParams(column);
return {
id: columnId,
enabled: true,
type: column.params.type,
schema: 'segment',
params,
};
},
paramEditor: ({ state, setState, currentColumn, layerId, columnId, uiSettings, data }) => {
const rangeFormatter = data.fieldFormats.deserialize({ id: 'range' });
const MAX_HISTOGRAM_BARS = uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS);
const granularityStep = (MAX_HISTOGRAM_BARS - MIN_HISTOGRAM_BARS) / SLICES;
const maxBarsDefaultValue = (MAX_HISTOGRAM_BARS - MIN_HISTOGRAM_BARS) / 2;
// Used to change one param at the time
const setParam: UpdateParamsFnType = (paramName, value) => {
setState(
updateColumnParam({
state,
layerId,
currentColumn,
paramName,
value,
})
);
};
// Useful to change more params at once
const onChangeMode = (newMode: MODES_TYPES) => {
const scale = newMode === MODES.Range ? 'ordinal' : 'interval';
const dataType = newMode === MODES.Range ? 'string' : 'number';
setState(
changeColumn({
state,
layerId,
columnId,
newColumn: {
...currentColumn,
scale,
dataType,
params: {
type: newMode,
ranges: [{ from: 0, to: DEFAULT_INTERVAL, label: '' }],
maxBars: maxBarsDefaultValue,
},
},
keepParams: false,
})
);
};
return (
<RangeEditor
setParam={setParam}
maxBars={
currentColumn.params.maxBars === AUTO_BARS
? maxBarsDefaultValue
: currentColumn.params.maxBars
}
granularityStep={granularityStep}
params={currentColumn.params}
onChangeMode={onChangeMode}
maxHistogramBars={uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS)}
rangeFormatter={rangeFormatter}
/>
);
},
};

View file

@ -35,6 +35,7 @@ interface BucketContainerProps {
invalidMessage: string;
onRemoveClick: () => void;
removeTitle: string;
isNotRemovable?: boolean;
children: React.ReactNode;
dataTestSubj?: string;
}
@ -46,6 +47,7 @@ const BucketContainer = ({
removeTitle,
children,
dataTestSubj,
isNotRemovable,
}: BucketContainerProps) => {
return (
<EuiPanel paddingSize="none" data-test-subj={dataTestSubj}>
@ -75,6 +77,7 @@ const BucketContainer = ({
onClick={onRemoveClick}
aria-label={removeTitle}
title={removeTitle}
disabled={isNotRemovable}
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -6,7 +6,7 @@
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiForm, EuiFormRow, EuiRange, EuiSelect } from '@elastic/eui';
import { EuiFormRow, EuiRange, EuiSelect } from '@elastic/eui';
import { IndexPatternColumn } from '../../indexpattern';
import { updateColumnParam } from '../../state_helpers';
import { DataType } from '../../../types';
@ -171,7 +171,7 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn> = {
}),
});
return (
<EuiForm>
<>
<EuiFormRow
label={i18n.translate('xpack.lens.indexPattern.terms.size', {
defaultMessage: 'Number of values',
@ -274,7 +274,7 @@ export const termsOperation: OperationDefinition<TermsIndexPatternColumn> = {
})}
/>
</EuiFormRow>
</EuiForm>
</>
);
},
};

View file

@ -225,6 +225,34 @@ describe('getOperationTypesForField', () => {
it('should list out all field-operation tuples for different operation meta data', () => {
expect(getAvailableOperationsByMetadata(expectedIndexPatterns[1])).toMatchInlineSnapshot(`
Array [
Object {
"operationMetaData": Object {
"dataType": "date",
"isBucketed": true,
"scale": "interval",
},
"operations": Array [
Object {
"field": "timestamp",
"operationType": "date_histogram",
"type": "field",
},
],
},
Object {
"operationMetaData": Object {
"dataType": "number",
"isBucketed": true,
"scale": "interval",
},
"operations": Array [
Object {
"field": "bytes",
"operationType": "range",
"type": "field",
},
],
},
Object {
"operationMetaData": Object {
"dataType": "number",
@ -253,20 +281,6 @@ describe('getOperationTypesForField', () => {
},
],
},
Object {
"operationMetaData": Object {
"dataType": "date",
"isBucketed": true,
"scale": "interval",
},
"operations": Array [
Object {
"field": "timestamp",
"operationType": "date_histogram",
"type": "field",
},
],
},
Object {
"operationMetaData": Object {
"dataType": "number",

View file

@ -17,7 +17,6 @@ import {
EuiFormRow,
EuiText,
htmlIdGenerator,
EuiForm,
EuiColorPicker,
EuiColorPickerProps,
EuiToolTip,
@ -366,7 +365,7 @@ export function DimensionEditor(props: VisualizationDimensionEditorProps<State>)
'auto';
return (
<EuiForm>
<>
<ColorPicker {...props} />
<EuiFormRow
@ -430,7 +429,7 @@ export function DimensionEditor(props: VisualizationDimensionEditorProps<State>)
}}
/>
</EuiFormRow>
</EuiForm>
</>
);
}

View file

@ -24,6 +24,7 @@ import {
ExpressionFunctionDefinition,
ExpressionRenderDefinition,
ExpressionValueSearchContext,
KibanaDatatable,
} from 'src/plugins/expressions/public';
import { IconType } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
@ -251,6 +252,12 @@ export function XYChart({
({ id }) => id === filteredLayers[0].xAccessor
);
const xAxisFormatter = formatFactory(xAxisColumn && xAxisColumn.formatHint);
const layersAlreadyFormatted: Record<string, boolean> = {};
// This is a safe formatter for the xAccessor that abstracts the knowledge of already formatted layers
const safeXAccessorLabelRenderer = (value: unknown): string =>
xAxisColumn && layersAlreadyFormatted[xAxisColumn.id]
? (value as string)
: xAxisFormatter.convert(value);
const chartHasMoreThanOneSeries =
filteredLayers.length > 1 ||
@ -364,7 +371,7 @@ export function XYChart({
theme={chartTheme}
baseTheme={chartBaseTheme}
tooltip={{
headerFormatter: (d) => xAxisFormatter.convert(d.value),
headerFormatter: (d) => safeXAccessorLabelRenderer(d.value),
}}
rotation={shouldRotate ? 90 : 0}
xDomain={xDomain}
@ -409,9 +416,15 @@ export function XYChart({
const points = [
{
row: table.rows.findIndex(
(row) => layer.xAccessor && row[layer.xAccessor] === xyGeometry.x
),
row: table.rows.findIndex((row) => {
if (layer.xAccessor) {
if (layersAlreadyFormatted[layer.xAccessor]) {
// stringify the value to compare with the chart value
return xAxisFormatter.convert(row[layer.xAccessor]) === xyGeometry.x;
}
return row[layer.xAccessor] === xyGeometry.x;
}
}),
column: table.columns.findIndex((col) => col.id === layer.xAccessor),
value: xyGeometry.x,
},
@ -455,7 +468,7 @@ export function XYChart({
strokeWidth: 2,
}}
hide={filteredLayers[0].hide || !filteredLayers[0].xAccessor}
tickFormat={(d) => xAxisFormatter.convert(d)}
tickFormat={(d) => safeXAccessorLabelRenderer(d)}
style={{
tickLabel: {
visible: tickLabelsVisibilitySettings?.x,
@ -504,9 +517,43 @@ export function XYChart({
const table = data.tables[layerId];
const isPrimitive = (value: unknown): boolean =>
value != null && typeof value !== 'object';
// what if row values are not primitive? That is the case of, for instance, Ranges
// remaps them to their serialized version with the formatHint metadata
// In order to do it we need to make a copy of the table as the raw one is required for more features (filters, etc...) later on
const tableConverted: KibanaDatatable = {
...table,
rows: table.rows.map((row) => {
const newRow = { ...row };
for (const column of table.columns) {
const record = newRow[column.id];
if (record && !isPrimitive(record)) {
newRow[column.id] = formatFactory(column.formatHint).convert(record);
}
}
return newRow;
}),
};
// save the id of the layer with the custom table
table.columns.reduce<Record<string, boolean>>(
(alreadyFormatted: Record<string, boolean>, { id }) => {
if (alreadyFormatted[id]) {
return alreadyFormatted;
}
alreadyFormatted[id] = table.rows.some(
(row, i) => row[id] !== tableConverted.rows[i][id]
);
return alreadyFormatted;
},
layersAlreadyFormatted
);
// For date histogram chart type, we're getting the rows that represent intervals without data.
// To not display them in the legend, they need to be filtered out.
const rows = table.rows.filter(
const rows = tableConverted.rows.filter(
(row) =>
!(xAccessor && typeof row[xAccessor] === 'undefined') &&
!(
@ -559,19 +606,28 @@ export function XYChart({
// * Key - Y name
// * Formatted value - Y name
if (accessors.length > 1) {
return d.seriesKeys
const result = d.seriesKeys
.map((key: string | number, i) => {
if (i === 0 && splitHint) {
if (
i === 0 &&
splitHint &&
splitAccessor &&
!layersAlreadyFormatted[splitAccessor]
) {
return formatFactory(splitHint).convert(key);
}
return splitAccessor && i === 0 ? key : columnToLabelMap[key] ?? '';
})
.join(' - ');
return result;
}
// For formatted split series, format the key
// This handles splitting by dates, for example
if (splitHint) {
if (splitAccessor && layersAlreadyFormatted[splitAccessor]) {
return d.seriesKeys[0];
}
return formatFactory(splitHint).convert(d.seriesKeys[0]);
}
// This handles both split and single-y cases: