mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Lens] Allow last value, min and max on dates, allow last value on ip_range, number_range and date_range (#125389)
* allow last value, min and max on dates * fix tests * fix typo * fix tests * nit Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
79a05ed41b
commit
595a6c367e
18 changed files with 218 additions and 28 deletions
|
@ -13,9 +13,11 @@ function isValidNumber(value: unknown): boolean {
|
|||
}
|
||||
|
||||
export function isNumericFieldForDatatable(currentData: Datatable | undefined, accessor: string) {
|
||||
const isNumeric =
|
||||
currentData?.columns.find((col) => col.id === accessor || getOriginalId(col.id) === accessor)
|
||||
?.meta.type === 'number';
|
||||
const column = currentData?.columns.find(
|
||||
(col) => col.id === accessor || getOriginalId(col.id) === accessor
|
||||
);
|
||||
// min and max aggs are reporting as number but are actually dates - work around this by checking for the date formatter until this is fixed at the source
|
||||
const isNumeric = column?.meta.type === 'number' && column?.meta.params?.id !== 'date';
|
||||
|
||||
return (
|
||||
isNumeric &&
|
||||
|
|
|
@ -367,6 +367,9 @@ export const getDatatableVisualization = ({
|
|||
|
||||
const hasNoSummaryRow = column.summaryRow == null || column.summaryRow === 'none';
|
||||
|
||||
const canColor =
|
||||
datasource!.getOperationForColumnId(column.columnId)?.dataType === 'number';
|
||||
|
||||
return {
|
||||
type: 'expression',
|
||||
chain: [
|
||||
|
@ -383,7 +386,7 @@ export const getDatatableVisualization = ({
|
|||
!datasource!.getOperationForColumnId(column.columnId)?.isBucketed,
|
||||
],
|
||||
alignment: typeof column.alignment === 'undefined' ? [] : [column.alignment],
|
||||
colorMode: [column.colorMode ?? 'none'],
|
||||
colorMode: [canColor && column.colorMode ? column.colorMode : 'none'],
|
||||
palette: [paletteService.get(CUSTOM_PALETTE).toExpression(paletteParams)],
|
||||
summaryRow: hasNoSummaryRow ? [] : [column.summaryRow!],
|
||||
summaryLabel: hasNoSummaryRow
|
||||
|
|
|
@ -186,7 +186,35 @@ describe('heatmap suggestions', () => {
|
|||
table: {
|
||||
layerId: 'first',
|
||||
isMultiRow: true,
|
||||
columns: [],
|
||||
columns: [
|
||||
{
|
||||
columnId: 'date-column-01',
|
||||
operation: {
|
||||
isBucketed: true,
|
||||
dataType: 'date',
|
||||
scale: 'interval',
|
||||
label: 'Date',
|
||||
},
|
||||
},
|
||||
{
|
||||
columnId: 'another-bucket-column',
|
||||
operation: {
|
||||
isBucketed: true,
|
||||
dataType: 'string',
|
||||
scale: 'ratio',
|
||||
label: 'Bucket',
|
||||
},
|
||||
},
|
||||
{
|
||||
columnId: 'metric-column',
|
||||
operation: {
|
||||
isBucketed: false,
|
||||
dataType: 'number',
|
||||
scale: 'ratio',
|
||||
label: 'Metric',
|
||||
},
|
||||
},
|
||||
],
|
||||
changeType: 'initial',
|
||||
},
|
||||
state: {
|
||||
|
@ -208,7 +236,35 @@ describe('heatmap suggestions', () => {
|
|||
table: {
|
||||
layerId: 'first',
|
||||
isMultiRow: true,
|
||||
columns: [],
|
||||
columns: [
|
||||
{
|
||||
columnId: 'date-column-01',
|
||||
operation: {
|
||||
isBucketed: true,
|
||||
dataType: 'date',
|
||||
scale: 'interval',
|
||||
label: 'Date',
|
||||
},
|
||||
},
|
||||
{
|
||||
columnId: 'another-bucket-column',
|
||||
operation: {
|
||||
isBucketed: true,
|
||||
dataType: 'string',
|
||||
scale: 'ratio',
|
||||
label: 'Bucket',
|
||||
},
|
||||
},
|
||||
{
|
||||
columnId: 'metric-column',
|
||||
operation: {
|
||||
isBucketed: false,
|
||||
dataType: 'number',
|
||||
scale: 'ratio',
|
||||
label: 'Metric',
|
||||
},
|
||||
},
|
||||
],
|
||||
changeType: 'reduced',
|
||||
},
|
||||
state: {
|
||||
|
@ -223,6 +279,9 @@ describe('heatmap suggestions', () => {
|
|||
layerId: 'first',
|
||||
layerType: layerTypes.DATA,
|
||||
shape: 'heatmap',
|
||||
valueAccessor: 'metric-column',
|
||||
xAccessor: 'date-column-01',
|
||||
yAccessor: 'another-bucket-column',
|
||||
gridConfig: {
|
||||
type: HEATMAP_GRID_FUNCTION,
|
||||
isCellLabelVisible: false,
|
||||
|
@ -240,7 +299,7 @@ describe('heatmap suggestions', () => {
|
|||
title: 'Heat map',
|
||||
hide: true,
|
||||
previewIcon: 'empty',
|
||||
score: 0,
|
||||
score: 0.3,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
|
@ -22,7 +22,9 @@ export const getSuggestions: Visualization<HeatmapVisualizationState>['getSugges
|
|||
(state?.shape === CHART_SHAPES.HEATMAP &&
|
||||
(state.xAccessor || state.yAccessor || state.valueAccessor) &&
|
||||
table.changeType !== 'extended') ||
|
||||
table.columns.some((col) => col.operation.isStaticValue)
|
||||
table.columns.some((col) => col.operation.isStaticValue) ||
|
||||
// do not use suggestions with non-numeric metrics
|
||||
table.columns.some((col) => !col.operation.isBucketed && col.operation.dataType !== 'number')
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
@ -44,6 +46,10 @@ export const getSuggestions: Visualization<HeatmapVisualizationState>['getSugges
|
|||
|
||||
const [groups, metrics] = partition(table.columns, (col) => col.operation.isBucketed);
|
||||
|
||||
if (groups.length === 0 && metrics.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (groups.length >= 3) {
|
||||
return [];
|
||||
}
|
||||
|
|
|
@ -113,12 +113,12 @@ const expectedIndexPatterns = {
|
|||
};
|
||||
|
||||
const bytesColumn: GenericIndexPatternColumn = {
|
||||
label: 'Max of bytes',
|
||||
label: 'Sum of bytes',
|
||||
dataType: 'number',
|
||||
isBucketed: false,
|
||||
|
||||
// Private
|
||||
operationType: 'max',
|
||||
operationType: 'sum',
|
||||
sourceField: 'bytes',
|
||||
params: { format: { id: 'bytes' } },
|
||||
};
|
||||
|
@ -529,7 +529,7 @@ describe('IndexPatternDimensionEditorPanel', () => {
|
|||
columns: {
|
||||
...state.layers.first.columns,
|
||||
col1: expect.objectContaining({
|
||||
operationType: 'max',
|
||||
operationType: 'sum',
|
||||
sourceField: 'memory',
|
||||
params: { format: { id: 'bytes' } },
|
||||
// Other parts of this don't matter for this test
|
||||
|
@ -728,7 +728,7 @@ describe('IndexPatternDimensionEditorPanel', () => {
|
|||
act(() => {
|
||||
wrapper
|
||||
.find('input[data-test-subj="indexPattern-label-edit"]')
|
||||
.simulate('change', { target: { value: 'Maximum of bytes' } });
|
||||
.simulate('change', { target: { value: 'Sum of bytes' } });
|
||||
});
|
||||
|
||||
expect(setState.mock.calls[0]).toEqual([expect.any(Function)]);
|
||||
|
@ -740,7 +740,7 @@ describe('IndexPatternDimensionEditorPanel', () => {
|
|||
columns: {
|
||||
...state.layers.first.columns,
|
||||
col1: expect.objectContaining({
|
||||
label: 'Maximum of bytes',
|
||||
label: 'Sum of bytes',
|
||||
customLabel: false,
|
||||
// Other parts of this don't matter for this test
|
||||
}),
|
||||
|
|
|
@ -698,7 +698,7 @@ describe('IndexPatternDimensionEditorPanel', () => {
|
|||
'replace_duplicate_incompatible',
|
||||
'swap_incompatible',
|
||||
],
|
||||
nextLabel: 'Unique count',
|
||||
nextLabel: 'Minimum',
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -736,7 +736,7 @@ describe('IndexPatternDimensionEditorPanel', () => {
|
|||
})
|
||||
).toEqual({
|
||||
dropTypes: ['replace_incompatible', 'replace_duplicate_incompatible'],
|
||||
nextLabel: 'Unique count',
|
||||
nextLabel: 'Minimum',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -511,17 +511,18 @@ describe('last_value', () => {
|
|||
]);
|
||||
});
|
||||
it('shows error message if the sourceField is of unsupported type', () => {
|
||||
indexPattern.getFieldByName('start_date')!.type = 'unsupported_type';
|
||||
errorLayer = {
|
||||
...errorLayer,
|
||||
columns: {
|
||||
col1: {
|
||||
...errorLayer.columns.col1,
|
||||
sourceField: 'timestamp',
|
||||
sourceField: 'start_date',
|
||||
} as LastValueIndexPatternColumn,
|
||||
},
|
||||
};
|
||||
expect(lastValueOperation.getErrorMessage!(errorLayer, 'col1', indexPattern)).toEqual([
|
||||
'Field timestamp is of the wrong type',
|
||||
'Field start_date is of the wrong type',
|
||||
]);
|
||||
});
|
||||
it('shows error message if the sortField is not date', () => {
|
||||
|
|
|
@ -39,7 +39,16 @@ function ofName(name: string, timeShift: string | undefined) {
|
|||
);
|
||||
}
|
||||
|
||||
const supportedTypes = new Set(['string', 'boolean', 'number', 'ip']);
|
||||
const supportedTypes = new Set([
|
||||
'string',
|
||||
'boolean',
|
||||
'number',
|
||||
'ip',
|
||||
'date',
|
||||
'ip_range',
|
||||
'number_range',
|
||||
'date_range',
|
||||
]);
|
||||
|
||||
export function getInvalidSortFieldMessage(sortField: string, indexPattern?: IndexPattern) {
|
||||
if (!indexPattern) {
|
||||
|
|
|
@ -48,6 +48,7 @@ function buildMetricOperation<T extends MetricColumn<string>>({
|
|||
ofName,
|
||||
priority,
|
||||
optionalTimeScaling,
|
||||
supportsDate,
|
||||
}: {
|
||||
type: T['operationType'];
|
||||
displayName: string;
|
||||
|
@ -55,6 +56,7 @@ function buildMetricOperation<T extends MetricColumn<string>>({
|
|||
priority?: number;
|
||||
optionalTimeScaling?: boolean;
|
||||
description?: string;
|
||||
supportsDate?: boolean;
|
||||
}) {
|
||||
const labelLookup = (name: string, column?: BaseIndexPatternColumn) => {
|
||||
const label = ofName(name);
|
||||
|
@ -76,12 +78,12 @@ function buildMetricOperation<T extends MetricColumn<string>>({
|
|||
timeScalingMode: optionalTimeScaling ? 'optional' : undefined,
|
||||
getPossibleOperationForField: ({ aggregationRestrictions, aggregatable, type: fieldType }) => {
|
||||
if (
|
||||
supportedTypes.includes(fieldType) &&
|
||||
(supportedTypes.includes(fieldType) || (supportsDate && fieldType === 'date')) &&
|
||||
aggregatable &&
|
||||
(!aggregationRestrictions || aggregationRestrictions[type])
|
||||
) {
|
||||
return {
|
||||
dataType: 'number',
|
||||
dataType: fieldType === 'date' ? 'date' : 'number',
|
||||
isBucketed: false,
|
||||
scale: 'ratio',
|
||||
};
|
||||
|
@ -105,7 +107,7 @@ function buildMetricOperation<T extends MetricColumn<string>>({
|
|||
buildColumn: ({ field, previousColumn }, columnParams) => {
|
||||
return {
|
||||
label: labelLookup(field.displayName, previousColumn),
|
||||
dataType: 'number',
|
||||
dataType: supportsDate && field.type === 'date' ? 'date' : 'number',
|
||||
operationType: type,
|
||||
sourceField: field.name,
|
||||
isBucketed: false,
|
||||
|
@ -120,6 +122,7 @@ function buildMetricOperation<T extends MetricColumn<string>>({
|
|||
return {
|
||||
...oldColumn,
|
||||
label: labelLookup(field.displayName, oldColumn),
|
||||
dataType: field.type,
|
||||
sourceField: field.name,
|
||||
};
|
||||
},
|
||||
|
@ -186,6 +189,7 @@ export const minOperation = buildMetricOperation<MinIndexPatternColumn>({
|
|||
defaultMessage:
|
||||
'A single-value metrics aggregation that returns the minimum value among the numeric values extracted from the aggregated documents.',
|
||||
}),
|
||||
supportsDate: true,
|
||||
});
|
||||
|
||||
export const maxOperation = buildMetricOperation<MaxIndexPatternColumn>({
|
||||
|
@ -202,6 +206,7 @@ export const maxOperation = buildMetricOperation<MaxIndexPatternColumn>({
|
|||
defaultMessage:
|
||||
'A single-value metrics aggregation that returns the maximum value among the numeric values extracted from the aggregated documents.',
|
||||
}),
|
||||
supportsDate: true,
|
||||
});
|
||||
|
||||
export const averageOperation = buildMetricOperation<AvgIndexPatternColumn>({
|
||||
|
|
|
@ -380,6 +380,30 @@ describe('getOperationTypesForField', () => {
|
|||
},
|
||||
],
|
||||
},
|
||||
Object {
|
||||
"operationMetaData": Object {
|
||||
"dataType": "date",
|
||||
"isBucketed": false,
|
||||
"scale": "ratio",
|
||||
},
|
||||
"operations": Array [
|
||||
Object {
|
||||
"field": "timestamp",
|
||||
"operationType": "min",
|
||||
"type": "field",
|
||||
},
|
||||
Object {
|
||||
"field": "timestamp",
|
||||
"operationType": "max",
|
||||
"type": "field",
|
||||
},
|
||||
Object {
|
||||
"field": "timestamp",
|
||||
"operationType": "last_value",
|
||||
"type": "field",
|
||||
},
|
||||
],
|
||||
},
|
||||
Object {
|
||||
"operationMetaData": Object {
|
||||
"dataType": "string",
|
||||
|
|
|
@ -210,6 +210,7 @@ describe('metric_expression', () => {
|
|||
>
|
||||
<AutoScale
|
||||
key="3"
|
||||
minScale={0.05}
|
||||
>
|
||||
<div
|
||||
className="lnsMetricExpression__value"
|
||||
|
|
|
@ -145,7 +145,13 @@ export function MetricChart({
|
|||
|
||||
return (
|
||||
<VisualizationContainer className="lnsMetricExpression__container" style={color}>
|
||||
<AutoScale key={value} titlePosition={titlePosition} textAlign={textAlign} size={size}>
|
||||
<AutoScale
|
||||
key={value}
|
||||
titlePosition={titlePosition}
|
||||
textAlign={textAlign}
|
||||
size={size}
|
||||
minScale={mode === 'full' ? undefined : 0.05}
|
||||
>
|
||||
{mode === 'full' && (
|
||||
<div
|
||||
data-test-subj="lns_metric_title"
|
||||
|
|
|
@ -111,6 +111,28 @@ describe('metric_suggestions', () => {
|
|||
|
||||
expect(suggestion).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('does not suggest for a bucketed value', () => {
|
||||
const col = {
|
||||
columnId: 'id',
|
||||
operation: {
|
||||
dataType: 'number',
|
||||
label: `Top values`,
|
||||
isBucketed: true,
|
||||
},
|
||||
} as const;
|
||||
const suggestion = getSuggestions({
|
||||
table: {
|
||||
columns: [col],
|
||||
isMultiRow: false,
|
||||
layerId: 'l1',
|
||||
changeType: 'unchanged',
|
||||
},
|
||||
keptLayerIds: [],
|
||||
});
|
||||
|
||||
expect(suggestion).toHaveLength(0);
|
||||
});
|
||||
test('suggests a basic metric chart', () => {
|
||||
const [suggestion, ...rest] = getSuggestions({
|
||||
table: {
|
||||
|
@ -137,6 +159,41 @@ describe('metric_suggestions', () => {
|
|||
`);
|
||||
});
|
||||
|
||||
test('suggests a basic metric chart for non bucketed date value', () => {
|
||||
const [suggestion, ...rest] = getSuggestions({
|
||||
table: {
|
||||
columns: [
|
||||
{
|
||||
columnId: 'id',
|
||||
operation: {
|
||||
dataType: 'date',
|
||||
label: 'Last value of x',
|
||||
isBucketed: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
isMultiRow: false,
|
||||
layerId: 'l1',
|
||||
changeType: 'unchanged',
|
||||
},
|
||||
keptLayerIds: [],
|
||||
});
|
||||
|
||||
expect(rest).toHaveLength(0);
|
||||
expect(suggestion).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"previewIcon": [Function],
|
||||
"score": 0.1,
|
||||
"state": Object {
|
||||
"accessor": "id",
|
||||
"layerId": "l1",
|
||||
"layerType": "data",
|
||||
},
|
||||
"title": "Last value of x",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('does not suggest for multiple layers', () => {
|
||||
const suggestions = getSuggestions({
|
||||
table: {
|
||||
|
|
|
@ -9,6 +9,7 @@ import { SuggestionRequest, VisualizationSuggestion, TableSuggestion } from '../
|
|||
import type { MetricState } from '../../common/expressions';
|
||||
import { layerTypes } from '../../common';
|
||||
import { LensIconChartMetric } from '../assets/chart_metric';
|
||||
import { supportedTypes } from './visualization';
|
||||
|
||||
/**
|
||||
* Generate suggestions for the metric chart.
|
||||
|
@ -26,7 +27,8 @@ export function getSuggestions({
|
|||
keptLayerIds.length > 1 ||
|
||||
(keptLayerIds.length && table.layerId !== keptLayerIds[0]) ||
|
||||
table.columns.length !== 1 ||
|
||||
table.columns[0].operation.dataType !== 'number' ||
|
||||
table.columns[0].operation.isBucketed ||
|
||||
!supportedTypes.has(table.columns[0].operation.dataType) ||
|
||||
table.columns[0].operation.isStaticValue
|
||||
) {
|
||||
return [];
|
||||
|
|
|
@ -23,6 +23,8 @@ import { CUSTOM_PALETTE, shiftPalette } from '../shared_components';
|
|||
import { MetricDimensionEditor } from './dimension_editor';
|
||||
import { MetricToolbar } from './metric_config_panel';
|
||||
|
||||
export const supportedTypes = new Set(['string', 'boolean', 'number', 'ip', 'date']);
|
||||
|
||||
const toExpression = (
|
||||
paletteService: PaletteRegistry,
|
||||
state: MetricState,
|
||||
|
@ -39,6 +41,8 @@ const toExpression = (
|
|||
const stops = state.palette?.params?.stops || [];
|
||||
const isCustomPalette = state.palette?.params?.name === CUSTOM_PALETTE;
|
||||
|
||||
const canColor = operation?.dataType === 'number';
|
||||
|
||||
const paletteParams = {
|
||||
...state.palette?.params,
|
||||
colors: stops.map(({ color }) => color),
|
||||
|
@ -67,7 +71,7 @@ const toExpression = (
|
|||
metricTitle: [operation?.label || ''],
|
||||
accessor: [state.accessor],
|
||||
mode: [attributes?.mode || 'full'],
|
||||
colorMode: [state?.colorMode || ColorMode.None],
|
||||
colorMode: !canColor ? [ColorMode.None] : [state?.colorMode || ColorMode.None],
|
||||
palette:
|
||||
state?.colorMode && state?.colorMode !== ColorMode.None
|
||||
? [paletteService.get(CUSTOM_PALETTE).toExpression(paletteParams)]
|
||||
|
@ -155,7 +159,8 @@ export const getMetricVisualization = ({
|
|||
]
|
||||
: [],
|
||||
supportsMoreColumns: !props.state.accessor,
|
||||
filterOperations: (op: OperationMetadata) => !op.isBucketed && op.dataType === 'number',
|
||||
filterOperations: (op: OperationMetadata) =>
|
||||
!op.isBucketed && supportedTypes.has(op.dataType),
|
||||
enableDimensionEditor: true,
|
||||
required: true,
|
||||
},
|
||||
|
|
|
@ -81,7 +81,15 @@ export function suggestions({
|
|||
return [];
|
||||
}
|
||||
|
||||
const [groups, metrics] = partition(table.columns, (col) => col.operation.isBucketed);
|
||||
const [groups, metrics] = partition(
|
||||
// filter out all metrics which are not number based
|
||||
table.columns.filter((col) => col.operation.isBucketed || col.operation.dataType === 'number'),
|
||||
(col) => col.operation.isBucketed
|
||||
);
|
||||
|
||||
if (groups.length === 0 && metrics.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (metrics.length > 1 || groups.length > maximumGroupLength) {
|
||||
return [];
|
||||
|
|
|
@ -1031,7 +1031,7 @@ describe('xy_suggestions', () => {
|
|||
columnId: 'mybool',
|
||||
operation: {
|
||||
dataType: 'boolean',
|
||||
isBucketed: false,
|
||||
isBucketed: true,
|
||||
label: 'Yes / No',
|
||||
},
|
||||
},
|
||||
|
|
|
@ -71,7 +71,9 @@ export function getSuggestions({
|
|||
|
||||
if (
|
||||
(incompleteTable && state && !subVisualizationId) ||
|
||||
table.columns.some((col) => col.operation.isStaticValue)
|
||||
table.columns.some((col) => col.operation.isStaticValue) ||
|
||||
// do not use suggestions with non-numeric metrics
|
||||
table.columns.some((col) => !col.operation.isBucketed && col.operation.dataType !== 'number')
|
||||
) {
|
||||
// reject incomplete configurations if the sub visualization isn't specifically requested
|
||||
// this allows to switch chart types via switcher with incomplete configurations, but won't
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue