[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:
Joe Reuter 2022-02-16 13:15:42 +01:00 committed by GitHub
parent 79a05ed41b
commit 595a6c367e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 218 additions and 28 deletions

View file

@ -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 &&

View file

@ -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

View file

@ -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,
},
]);
});

View file

@ -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 [];
}

View file

@ -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
}),

View file

@ -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',
});
});

View file

@ -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', () => {

View file

@ -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) {

View file

@ -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>({

View file

@ -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",

View file

@ -210,6 +210,7 @@ describe('metric_expression', () => {
>
<AutoScale
key="3"
minScale={0.05}
>
<div
className="lnsMetricExpression__value"

View file

@ -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"

View file

@ -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: {

View file

@ -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 [];

View file

@ -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,
},

View file

@ -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 [];

View file

@ -1031,7 +1031,7 @@ describe('xy_suggestions', () => {
columnId: 'mybool',
operation: {
dataType: 'boolean',
isBucketed: false,
isBucketed: true,
label: 'Yes / No',
},
},

View file

@ -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