mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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:
parent
0d09cea436
commit
0f8043ca8d
17 changed files with 1374 additions and 33 deletions
|
@ -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 (
|
||||
|
|
|
@ -332,7 +332,6 @@ export function DimensionEditor(props: DimensionEditorProps) {
|
|||
|
||||
{!incompatibleSelectedOperationType && ParamEditor && (
|
||||
<>
|
||||
<EuiSpacer size="s" />
|
||||
<ParamEditor
|
||||
state={state}
|
||||
setState={setState}
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
.lnsRangesOperation__popoverButton {
|
||||
@include euiTextBreakWord;
|
||||
@include euiFontSizeS;
|
||||
min-height: $euiSizeXL;
|
||||
width: 100%;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
|
@ -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';
|
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue