[Lens] Add collapse fn to table and xy chart (#131748)

* add collapse fn to table and xy chart

* adjust documentation

* bug fixes

* tests and fixes

* fix bug

* allow color picking if collapse fn is active

Co-authored-by: Stratoula Kalafateli <efstratia.kalafateli@elastic.co>
This commit is contained in:
Joe Reuter 2022-05-16 17:24:29 +02:00 committed by GitHub
parent ca923bbdea
commit 81dce64f55
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 480 additions and 57 deletions

View file

@ -0,0 +1,96 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Datatable, ExecutionContext } from '@kbn/expressions-plugin/common';
import { functionWrapper } from '@kbn/expressions-plugin/common/expression_functions/specs/tests/utils';
import { CollapseArgs, collapse } from '.';
describe('collapse_fn', () => {
const fn = functionWrapper(collapse);
const runFn = (input: Datatable, args: CollapseArgs) =>
fn(input, args, {} as ExecutionContext) as Promise<Datatable>;
it('collapses all rows', async () => {
const result = await runFn(
{
type: 'datatable',
columns: [
{ id: 'val', name: 'val', meta: { type: 'number' } },
{ id: 'split', name: 'split', meta: { type: 'string' } },
],
rows: [
{ val: 1, split: 'A' },
{ val: 2, split: 'B' },
{ val: 3, split: 'B' },
{ val: 4, split: 'A' },
{ val: 5, split: 'A' },
{ val: 6, split: 'A' },
{ val: 7, split: 'B' },
{ val: 8, split: 'B' },
],
},
{ metric: ['val'], fn: 'sum' }
);
expect(result.rows).toEqual([{ val: 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 }]);
});
const twoSplitTable: Datatable = {
type: 'datatable',
columns: [
{ id: 'val', name: 'val', meta: { type: 'number' } },
{ id: 'split', name: 'split', meta: { type: 'string' } },
{ id: 'split2', name: 'split2', meta: { type: 'string' } },
],
rows: [
{ val: 1, split: 'A', split2: 'C' },
{ val: 2, split: 'B', split2: 'C' },
{ val: 3, split2: 'C' },
{ val: 4, split: 'A', split2: 'C' },
{ val: 5 },
{ val: 6, split: 'A', split2: 'D' },
{ val: 7, split: 'B', split2: 'D' },
{ val: 8, split: 'B', split2: 'D' },
],
};
it('splits by a column', async () => {
const result = await runFn(twoSplitTable, { metric: ['val'], by: ['split'], fn: 'sum' });
expect(result.rows).toEqual([
{ val: 1 + 4 + 6, split: 'A' },
{ val: 2 + 7 + 8, split: 'B' },
{ val: 3 + 5, split: undefined },
]);
});
it('applies avg', async () => {
const result = await runFn(twoSplitTable, { metric: ['val'], by: ['split'], fn: 'avg' });
expect(result.rows).toEqual([
{ val: (1 + 4 + 6) / 3, split: 'A' },
{ val: (2 + 7 + 8) / 3, split: 'B' },
{ val: (3 + 5) / 2, split: undefined },
]);
});
it('applies min', async () => {
const result = await runFn(twoSplitTable, { metric: ['val'], by: ['split'], fn: 'min' });
expect(result.rows).toEqual([
{ val: 1, split: 'A' },
{ val: 2, split: 'B' },
{ val: 3, split: undefined },
]);
});
it('applies max', async () => {
const result = await runFn(twoSplitTable, { metric: ['val'], by: ['split'], fn: 'max' });
expect(result.rows).toEqual([
{ val: 6, split: 'A' },
{ val: 8, split: 'B' },
{ val: 5, split: undefined },
]);
});
});

View file

@ -0,0 +1,101 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Datatable, DatatableRow, getBucketIdentifier } from '@kbn/expressions-plugin/common';
import type { CollapseExpressionFunction } from './types';
function getValueAsNumberArray(value: unknown) {
if (Array.isArray(value)) {
return value.map((innerVal) => Number(innerVal));
} else {
return [Number(value)];
}
}
export const collapseFn: CollapseExpressionFunction['fn'] = (input, { by, metric, fn }) => {
const accumulators: Record<string, Partial<Record<string, number>>> = {};
const valueCounter: Record<string, Partial<Record<string, number>>> = {};
metric?.forEach((m) => {
accumulators[m] = {};
valueCounter[m] = {};
});
const setMarker: Partial<Record<string, boolean>> = {};
input.rows.forEach((row) => {
const bucketIdentifier = getBucketIdentifier(row, by);
metric?.forEach((m) => {
const accumulatorValue = accumulators[m][bucketIdentifier];
const currentValue = row[m];
if (currentValue != null) {
const currentNumberValues = getValueAsNumberArray(currentValue);
switch (fn) {
case 'avg':
valueCounter[m][bucketIdentifier] =
(valueCounter[m][bucketIdentifier] ?? 0) + currentNumberValues.length;
case 'sum':
accumulators[m][bucketIdentifier] = currentNumberValues.reduce(
(a, b) => a + b,
accumulatorValue || 0
);
break;
case 'min':
if (typeof accumulatorValue !== 'undefined') {
accumulators[m][bucketIdentifier] = Math.min(
accumulatorValue,
...currentNumberValues
);
} else {
accumulators[m][bucketIdentifier] = Math.min(...currentNumberValues);
}
break;
case 'max':
if (typeof accumulatorValue !== 'undefined') {
accumulators[m][bucketIdentifier] = Math.max(
accumulatorValue,
...currentNumberValues
);
} else {
accumulators[m][bucketIdentifier] = Math.max(...currentNumberValues);
}
break;
}
}
});
});
if (fn === 'avg') {
metric?.forEach((m) => {
Object.keys(accumulators[m]).forEach((bucketIdentifier) => {
const accumulatorValue = accumulators[m][bucketIdentifier];
const valueCount = valueCounter[m][bucketIdentifier];
if (typeof accumulatorValue !== 'undefined' && typeof valueCount !== 'undefined') {
accumulators[m][bucketIdentifier] = accumulatorValue / valueCount;
}
});
});
}
return {
...input,
columns: input.columns.filter((c) => by?.indexOf(c.id) !== -1 || metric?.indexOf(c.id) !== -1),
rows: input.rows
.map((row) => {
const bucketIdentifier = getBucketIdentifier(row, by);
if (setMarker[bucketIdentifier]) return undefined;
setMarker[bucketIdentifier] = true;
const newRow: Datatable['rows'][number] = {};
metric?.forEach((m) => {
newRow[m] = accumulators[m][bucketIdentifier];
});
by?.forEach((b) => {
newRow[b] = row[b];
});
return newRow;
})
.filter(Boolean) as DatatableRow[],
};
};

View file

@ -0,0 +1,68 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import type { CollapseExpressionFunction } from './types';
export interface CollapseArgs {
by?: string[];
metric?: string[];
fn: 'sum' | 'avg' | 'min' | 'max';
}
/**
* Collapses multiple rows into a single row using the specified function.
*
* The `by` argument specifies the columns to group by - these columns are not collapsed.
* The `metric` arguments specifies the collumns to apply the aggregate function to.
*
* All other columns are removed.
*/
export const collapse: CollapseExpressionFunction = {
name: 'lens_collapse',
type: 'datatable',
inputTypes: ['datatable'],
help: i18n.translate('xpack.lens.functions.collapse.help', {
defaultMessage:
'Collapses multiple rows into a single row using the specified aggregate function.',
}),
args: {
by: {
help: i18n.translate('xpack.lens.functions.collapse.args.byHelpText', {
defaultMessage: 'Columns to group by - these columns are kept as-is',
}),
multi: true,
types: ['string'],
required: false,
},
metric: {
help: i18n.translate('xpack.lens.functions.collapse.args.metricHelpText', {
defaultMessage: 'Column to calculate the specified aggregate function of',
}),
types: ['string'],
multi: true,
required: false,
},
fn: {
help: i18n.translate('xpack.lens.functions.collapse.args.fnHelpText', {
defaultMessage: 'The aggregate function to apply',
}),
types: ['string'],
required: true,
},
},
async fn(...args) {
/** Build optimization: prevent adding extra code into initial bundle **/
const { collapseFn } = await import('./collapse_fn');
return collapseFn(...args);
},
};

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Datatable, ExpressionFunctionDefinition } from '@kbn/expressions-plugin';
import { CollapseArgs } from '.';
export type CollapseExpressionFunction = ExpressionFunctionDefinition<
'lens_collapse',
Datatable,
CollapseArgs,
Datatable | Promise<Datatable>
>;

View file

@ -42,6 +42,7 @@ export interface ColumnState {
colorMode?: 'none' | 'cell' | 'text';
summaryRow?: 'none' | 'sum' | 'avg' | 'count' | 'min' | 'max';
summaryLabel?: string;
collapseFn?: string;
}
export type DatatableColumnResult = ColumnState & { type: 'lens_datatable_column' };

View file

@ -47,6 +47,7 @@ export const datatableFn =
let untransposedData: Datatable | undefined;
// do the sorting at this level to propagate it also at CSV download
const [layerId] = Object.keys(context.inspectorAdapters.tables || {});
const formatters: Record<string, ReturnType<FormatFactory>> = {};
const formatFactory = await getFormatFactory(context);

View file

@ -6,6 +6,7 @@
*/
export * from './counter_rate';
export * from './collapse';
export * from './format_column';
export * from './rename_columns';
export * from './time_scale';

View file

@ -40,6 +40,7 @@ import {
} from '../../../common/expressions';
import './dimension_editor.scss';
import { CollapseSetting } from '../../shared_components/collapse_setting';
const idPrefix = htmlIdGenerator()();
@ -128,6 +129,17 @@ export function TableDimensionEditor(
return (
<>
{props.groupId === 'rows' && (
<CollapseSetting
value={column.collapseFn || ''}
onChange={(collapseFn: string) => {
setState({
...state,
columns: updateColumnWith(state, accessor, { collapseFn }),
});
}}
/>
)}
<EuiFormRow
display="columnCompressed"
fullWidth

View file

@ -490,7 +490,13 @@ describe('Datatable Visualization', () => {
describe('#toExpression', () => {
const getDatatableExpressionArgs = (state: DatatableVisualizationState) =>
buildExpression(
datatableVisualization.toExpression(state, frame.datasourceLayers) as Ast
datatableVisualization.toExpression(
state,
frame.datasourceLayers,
{},
{ '1': { type: 'expression', chain: [] } }
) as Ast
).findFunction('lens_datatable')[0].arguments;
const defaultExpressionTableState = {
@ -524,7 +530,9 @@ describe('Datatable Visualization', () => {
const expression = datatableVisualization.toExpression(
defaultExpressionTableState,
frame.datasourceLayers
frame.datasourceLayers,
{},
{ '1': { type: 'expression', chain: [] } }
) as Ast;
const tableArgs = buildExpression(expression).findFunction('lens_datatable');
@ -573,7 +581,9 @@ describe('Datatable Visualization', () => {
const expression = datatableVisualization.toExpression(
defaultExpressionTableState,
frame.datasourceLayers
frame.datasourceLayers,
{},
{ '1': { type: 'expression', chain: [] } }
);
expect(expression).toEqual(null);

View file

@ -7,7 +7,7 @@
import React from 'react';
import { render } from 'react-dom';
import { Ast } from '@kbn/interpreter';
import { Ast, AstFunction } from '@kbn/interpreter';
import { I18nProvider } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { PaletteRegistry, CUSTOM_PALETTE } from '@kbn/coloring';
@ -203,7 +203,10 @@ export const getDatatableVisualization = ({
)
.map((accessor) => ({
columnId: accessor,
triggerIcon: columnMap[accessor].hidden ? 'invisible' : undefined,
triggerIcon:
columnMap[accessor].hidden || columnMap[accessor].collapseFn
? 'invisible'
: undefined,
})),
supportsMoreColumns: true,
filterOperations: (op) => op.isBucketed,
@ -342,6 +345,10 @@ export const getDatatableVisualization = ({
return null;
}
if (!datasourceExpressionsByLayers || Object.keys(datasourceExpressionsByLayers).length === 0) {
return null;
}
const columnMap: Record<string, ColumnState> = {};
state.columns.forEach((column) => {
columnMap[column.columnId] = column;
@ -357,58 +364,84 @@ export const getDatatableVisualization = ({
type: 'expression',
chain: [
...(datasourceExpression?.chain ?? []),
...columns
.filter((c) => c.collapseFn)
.map((c) => {
return {
type: 'function',
function: 'lens_collapse',
arguments: {
by: columns
.filter(
(col) =>
col.columnId !== c.columnId &&
datasource!.getOperationForColumnId(col.columnId)?.isBucketed
)
.map((col) => col.columnId),
metric: columns
.filter((col) => !datasource!.getOperationForColumnId(col.columnId)?.isBucketed)
.map((col) => col.columnId),
fn: [c.collapseFn!],
},
} as AstFunction;
}),
{
type: 'function',
function: 'lens_datatable',
arguments: {
title: [title || ''],
description: [description || ''],
columns: columns.map((column) => {
const paletteParams = {
...column.palette?.params,
// rewrite colors and stops as two distinct arguments
colors: (column.palette?.params?.stops || []).map(({ color }) => color),
stops:
column.palette?.params?.name === 'custom'
? (column.palette?.params?.stops || []).map(({ stop }) => stop)
: [],
reverse: false, // managed at UI level
};
const sortingHint = datasource!.getOperationForColumnId(column.columnId)!.sortingHint;
columns: columns
.filter((c) => !c.collapseFn)
.map((column) => {
const paletteParams = {
...column.palette?.params,
// rewrite colors and stops as two distinct arguments
colors: (column.palette?.params?.stops || []).map(({ color }) => color),
stops:
column.palette?.params?.name === 'custom'
? (column.palette?.params?.stops || []).map(({ stop }) => stop)
: [],
reverse: false, // managed at UI level
};
const sortingHint = datasource!.getOperationForColumnId(
column.columnId
)!.sortingHint;
const hasNoSummaryRow = column.summaryRow == null || column.summaryRow === 'none';
const hasNoSummaryRow = column.summaryRow == null || column.summaryRow === 'none';
const canColor =
datasource!.getOperationForColumnId(column.columnId)?.dataType === 'number';
const canColor =
datasource!.getOperationForColumnId(column.columnId)?.dataType === 'number';
return {
type: 'expression',
chain: [
{
type: 'function',
function: 'lens_datatable_column',
arguments: {
columnId: [column.columnId],
hidden: typeof column.hidden === 'undefined' ? [] : [column.hidden],
width: typeof column.width === 'undefined' ? [] : [column.width],
isTransposed:
typeof column.isTransposed === 'undefined' ? [] : [column.isTransposed],
transposable: [
!datasource!.getOperationForColumnId(column.columnId)?.isBucketed,
],
alignment: typeof column.alignment === 'undefined' ? [] : [column.alignment],
colorMode: [canColor && column.colorMode ? column.colorMode : 'none'],
palette: [paletteService.get(CUSTOM_PALETTE).toExpression(paletteParams)],
summaryRow: hasNoSummaryRow ? [] : [column.summaryRow!],
summaryLabel: hasNoSummaryRow
? []
: [column.summaryLabel ?? getDefaultSummaryLabel(column.summaryRow!)],
sortingHint: sortingHint ? [sortingHint] : [],
return {
type: 'expression',
chain: [
{
type: 'function',
function: 'lens_datatable_column',
arguments: {
columnId: [column.columnId],
hidden: typeof column.hidden === 'undefined' ? [] : [column.hidden],
width: typeof column.width === 'undefined' ? [] : [column.width],
isTransposed:
typeof column.isTransposed === 'undefined' ? [] : [column.isTransposed],
transposable: [
!datasource!.getOperationForColumnId(column.columnId)?.isBucketed,
],
alignment:
typeof column.alignment === 'undefined' ? [] : [column.alignment],
colorMode: [canColor && column.colorMode ? column.colorMode : 'none'],
palette: [paletteService.get(CUSTOM_PALETTE).toExpression(paletteParams)],
summaryRow: hasNoSummaryRow ? [] : [column.summaryRow!],
summaryLabel: hasNoSummaryRow
? []
: [column.summaryLabel ?? getDefaultSummaryLabel(column.summaryRow!)],
sortingHint: sortingHint ? [sortingHint] : [],
},
},
},
],
};
}),
],
};
}),
sortingColumnId: [state.sorting?.columnId || ''],
sortingDirection: [state.sorting?.direction || 'none'],
fitRowToContent: [state.rowHeight === 'auto'],

View file

@ -12,6 +12,7 @@ import { renameColumns } from '../common/expressions/rename_columns/rename_colum
import { formatColumn } from '../common/expressions/format_column';
import { counterRate } from '../common/expressions/counter_rate';
import { getTimeScale } from '../common/expressions/time_scale/time_scale';
import { collapse } from '../common/expressions';
export const setupExpressions = (
expressions: ExpressionsSetup,
@ -19,6 +20,7 @@ export const setupExpressions = (
getTimeZone: Parameters<typeof getTimeScale>[0]
) => {
[
collapse,
counterRate,
formatColumn,
renameColumns,

View file

@ -0,0 +1,44 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFormRow, EuiSelect } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
const options = [
{ text: i18n.translate('xpack.lens.collapse.none', { defaultMessage: 'None' }), value: '' },
{ text: i18n.translate('xpack.lens.collapse.sum', { defaultMessage: 'Sum' }), value: 'sum' },
{ text: i18n.translate('xpack.lens.collapse.min', { defaultMessage: 'Min' }), value: 'min' },
{ text: i18n.translate('xpack.lens.collapse.max', { defaultMessage: 'Max' }), value: 'max' },
{ text: i18n.translate('xpack.lens.collapse.avg', { defaultMessage: 'Average' }), value: 'avg' },
];
export function CollapseSetting({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
return (
<EuiFormRow
label={i18n.translate('xpack.lens.collapse.label', { defaultMessage: 'Collapse by' })}
display="columnCompressed"
fullWidth
>
<EuiSelect
compressed
data-test-subj="indexPattern-terms-orderBy"
options={options}
value={value}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
onChange(e.target.value);
}}
/>
</EuiFormRow>
);
}

View file

@ -48,7 +48,7 @@ export function getColorAssignments(
return mapValues(layersPerPalette, (paletteLayers) => {
const seriesPerLayer = paletteLayers.map((layer, layerIndex) => {
if (!layer.splitAccessor) {
if (layer.collapseFn || !layer.splitAccessor) {
return { numberOfSeries: layer.accessors.length, splits: [] };
}
const splitAccessor = layer.splitAccessor;
@ -108,7 +108,7 @@ export function getAccessorColorConfig(
if (isAnnotationsLayer(layer)) {
return getAnnotationsAccessorColorConfig(layer);
}
const layerContainsSplits = Boolean(layer.splitAccessor);
const layerContainsSplits = !layer.collapseFn && layer.splitAccessor;
const currentPalette: PaletteOutput = layer.palette || { type: 'palette', name: 'default' };
const totalSeriesCount = colorAssignments[currentPalette.name]?.totalSeriesCount;
return layer.accessors.map((accessor) => {

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { Ast } from '@kbn/interpreter';
import { Ast, AstFunction } from '@kbn/interpreter';
import { ScaleType } from '@elastic/charts';
import type { PaletteRegistry } from '@kbn/coloring';
@ -426,14 +426,38 @@ const dataLayerToExpression = (
],
xScaleType: [getScaleType(metadata[layer.layerId][layer.xAccessor], ScaleType.Linear)],
isHistogram: [isHistogramDimension],
splitAccessor: layer.splitAccessor ? [layer.splitAccessor] : [],
splitAccessor: layer.collapseFn || !layer.splitAccessor ? [] : [layer.splitAccessor],
yConfig: layer.yConfig
? layer.yConfig.map((yConfig) => yConfigToExpression(yConfig))
: [],
seriesType: [layer.seriesType],
accessors: layer.accessors,
columnToLabel: [JSON.stringify(columnToLabel)],
...(datasourceExpression ? { table: [datasourceExpression] } : {}),
...(datasourceExpression
? {
table: [
{
...datasourceExpression,
chain: [
...datasourceExpression.chain,
...(layer.collapseFn
? [
{
type: 'function',
function: 'lens_collapse',
arguments: {
by: layer.xAccessor ? [layer.xAccessor] : [],
metric: layer.accessors,
fn: [layer.collapseFn!],
},
} as AstFunction,
]
: []),
],
},
],
}
: {}),
palette: [
{
type: 'expression',

View file

@ -46,6 +46,7 @@ export interface XYDataLayerConfig {
yConfig?: YConfig[];
splitAccessor?: string;
palette?: PaletteOutput;
collapseFn?: string;
yScaleType?: YScaleType;
xScaleType?: XScaleType;
isHistogram?: boolean;

View file

@ -276,10 +276,12 @@ export const getXyVisualization = ({
? [
{
columnId: dataLayer.splitAccessor,
triggerIcon: 'colorBy' as const,
palette: paletteService
.get(dataLayer.palette?.name || 'default')
.getCategoricalColors(10, dataLayer.palette?.params),
triggerIcon: dataLayer.collapseFn ? ('invisible' as const) : ('colorBy' as const),
palette: dataLayer.collapseFn
? undefined
: paletteService
.get(dataLayer.palette?.name || 'default')
.getCategoricalColors(10, dataLayer.palette?.params),
},
]
: [],

View file

@ -19,6 +19,7 @@ import { PalettePicker, useDebouncedValue } from '../../shared_components';
import { isAnnotationsLayer, isReferenceLayer } from '../visualization_helpers';
import { ReferenceLinePanel } from './reference_line_config_panel';
import { AnnotationsPanel } from './annotations_config_panel';
import { CollapseSetting } from '../../shared_components/collapse_setting';
type UnwrapArray<T> = T extends Array<infer P> ? P : T;
@ -90,6 +91,12 @@ export function DimensionEditor(
if (props.groupId === 'breakdown') {
return (
<>
<CollapseSetting
value={layer.collapseFn || ''}
onChange={(collapseFn) => {
setLocalState(updateLayer(localState, { ...layer, collapseFn }, index));
}}
/>
<PalettePicker
palettes={props.paletteService}
activePalette={localLayer?.palette}
@ -105,7 +112,11 @@ export function DimensionEditor(
return (
<>
<ColorPicker {...props} disabled={Boolean(localLayer.splitAccessor)} setConfig={setConfig} />
<ColorPicker
{...props}
disabled={Boolean(!localLayer.collapseFn && localLayer.splitAccessor)}
setConfig={setConfig}
/>
<EuiFormRow
display="columnCompressed"