[Lens] Support metric trendlines (#141851)

This commit is contained in:
Andrew Tate 2022-10-18 14:50:51 -05:00 committed by GitHub
parent 836b60da22
commit 66041ca2c2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
84 changed files with 4708 additions and 1194 deletions

View file

@ -74,7 +74,7 @@ pageLoadAssetSize:
kibanaUsageCollection: 16463
kibanaUtils: 79713
kubernetesSecurity: 77234
lens: 36500
lens: 37000
licenseManagement: 41817
licensing: 29004
lists: 22900

View file

@ -7,6 +7,9 @@
*/
export const EXPRESSION_METRIC_NAME = 'metricVis';
export const EXPRESSION_METRIC_TRENDLINE_NAME = 'metricTrendline';
export const DEFAULT_TRENDLINE_NAME = 'default';
export const LabelPosition = {
BOTTOM: 'bottom',

View file

@ -0,0 +1,353 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`metric trendline function builds trends with breakdown 1`] = `
Object {
"css": Array [
Object {
"x": 1664121600000,
"y": 3264,
},
Object {
"x": 1664123400000,
"y": 7215,
},
Object {
"x": 1664125200000,
"y": 9601,
},
Object {
"x": 1664127000000,
"y": 8458,
},
],
"deb": Array [
Object {
"x": 1664121600000,
"y": NaN,
},
Object {
"x": 1664123400000,
"y": 9680,
},
Object {
"x": 1664125200000,
"y": NaN,
},
Object {
"x": 1664127000000,
"y": NaN,
},
],
"gz": Array [
Object {
"x": 1664121600000,
"y": 3116.5,
},
Object {
"x": 1664123400000,
"y": NaN,
},
Object {
"x": 1664125200000,
"y": 4148,
},
Object {
"x": 1664127000000,
"y": NaN,
},
],
"rpm": Array [
Object {
"x": 1664121600000,
"y": NaN,
},
Object {
"x": 1664123400000,
"y": NaN,
},
Object {
"x": 1664125200000,
"y": NaN,
},
Object {
"x": 1664127000000,
"y": NaN,
},
],
"zip": Array [
Object {
"x": 1664121600000,
"y": NaN,
},
Object {
"x": 1664123400000,
"y": NaN,
},
Object {
"x": 1664125200000,
"y": 5037,
},
Object {
"x": 1664127000000,
"y": NaN,
},
Object {
"x": 1664128800000,
"y": NaN,
},
],
}
`;
exports[`metric trendline function builds trends without breakdown 1`] = `
Object {
"default": Array [
Object {
"x": 1664121600000,
"y": null,
},
Object {
"x": 1664123400000,
"y": null,
},
Object {
"x": 1664125200000,
"y": null,
},
Object {
"x": 1664127000000,
"y": null,
},
],
}
`;
exports[`metric trendline function creates inspector information 1`] = `
Object {
"columns": Array [
Object {
"id": "breakdown",
"meta": Object {
"dimensionName": "Split group",
"field": "extension.keyword",
"index": "kibana_sample_data_logs",
"params": Object {
"id": "terms",
"params": Object {
"id": "string",
"missingBucketLabel": "(missing value)",
"otherBucketLabel": "Other",
},
},
"source": "esaggs",
"sourceParams": Object {
"enabled": true,
"hasPrecisionError": false,
"id": "0",
"indexPatternId": "90943e30-9a47-11e8-b64d-95841ca0b247",
"params": Object {
"excludeIsRegex": false,
"field": "extension.keyword",
"includeIsRegex": false,
"missingBucket": false,
"missingBucketLabel": "(missing value)",
"order": "desc",
"orderBy": "2",
"otherBucket": true,
"otherBucketLabel": "Other",
"size": 5,
},
"schema": "segment",
"type": "terms",
},
"type": "string",
},
"name": "Top 5 values of extension.keyword",
},
Object {
"id": "time",
"meta": Object {
"dimensionName": "Time field",
"field": "timestamp",
"index": "kibana_sample_data_logs",
"params": Object {
"id": "date",
"params": Object {
"pattern": "HH:mm",
},
},
"source": "esaggs",
"sourceParams": Object {
"appliedTimeRange": Object {
"from": "2022-09-25T16:00:00.000Z",
"to": "2022-09-26T16:12:41.742Z",
},
"enabled": true,
"hasPrecisionError": false,
"id": "1",
"indexPatternId": "90943e30-9a47-11e8-b64d-95841ca0b247",
"params": Object {
"drop_partials": false,
"extendToTimeRange": true,
"extended_bounds": Object {},
"field": "timestamp",
"interval": "auto",
"min_doc_count": 0,
"scaleMetricValues": false,
"timeRange": Object {
"from": "2022-09-25T16:00:00.000Z",
"to": "2022-09-26T16:12:41.742Z",
},
"useNormalizedEsInterval": true,
"used_interval": "30m",
},
"schema": "segment",
"type": "date_histogram",
},
"type": "date",
},
"name": "timestamp per 30 minutes",
},
Object {
"id": "metric",
"meta": Object {
"dimensionName": "Metric",
"field": "bytes",
"index": "kibana_sample_data_logs",
"params": Object {
"id": "number",
},
"source": "esaggs",
"sourceParams": Object {
"enabled": true,
"hasPrecisionError": false,
"id": "2",
"indexPatternId": "90943e30-9a47-11e8-b64d-95841ca0b247",
"params": Object {
"field": "bytes",
},
"schema": "metric",
"type": "median",
},
"type": "number",
},
"name": "Median of byts",
},
],
"meta": Object {
"source": "90943e30-9a47-11e8-b64d-95841ca0b247",
"statistics": Object {
"totalCount": 236,
},
"type": "esaggs",
},
"rows": Array [
Object {
"breakdown": "rpm",
"metric": null,
"time": 1664121600000,
},
Object {
"breakdown": "rpm",
"metric": null,
"time": 1664123400000,
},
Object {
"breakdown": "rpm",
"metric": null,
"time": 1664125200000,
},
Object {
"breakdown": "rpm",
"metric": null,
"time": 1664127000000,
},
Object {
"breakdown": "deb",
"metric": null,
"time": 1664121600000,
},
Object {
"breakdown": "deb",
"metric": 9680,
"time": 1664123400000,
},
Object {
"breakdown": "deb",
"metric": null,
"time": 1664125200000,
},
Object {
"breakdown": "deb",
"metric": null,
"time": 1664127000000,
},
Object {
"breakdown": "zip",
"metric": null,
"time": 1664121600000,
},
Object {
"breakdown": "zip",
"metric": null,
"time": 1664123400000,
},
Object {
"breakdown": "zip",
"metric": 5037,
"time": 1664125200000,
},
Object {
"breakdown": "zip",
"metric": null,
"time": 1664127000000,
},
Object {
"breakdown": "zip",
"metric": null,
"time": 1664128800000,
},
Object {
"breakdown": "css",
"metric": 3264,
"time": 1664121600000,
},
Object {
"breakdown": "css",
"metric": 7215,
"time": 1664123400000,
},
Object {
"breakdown": "css",
"metric": 9601,
"time": 1664125200000,
},
Object {
"breakdown": "css",
"metric": 8458,
"time": 1664127000000,
},
Object {
"breakdown": "gz",
"metric": 3116.5,
"time": 1664121600000,
},
Object {
"breakdown": "gz",
"metric": null,
"time": 1664123400000,
},
Object {
"breakdown": "gz",
"metric": 4148,
"time": 1664125200000,
},
Object {
"breakdown": "gz",
"metric": null,
"time": 1664127000000,
},
],
"type": "datatable",
}
`;

View file

@ -0,0 +1,397 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { Datatable, ExecutionContext } from '@kbn/expressions-plugin/common';
import { Adapters } from '@kbn/inspector-plugin/common';
import { SerializableRecord } from '@kbn/utility-types';
import { TrendlineArguments } from '../types';
import { metricTrendlineFunction } from './metric_trendline_function';
const fakeContext = {} as ExecutionContext<Adapters, SerializableRecord>;
const fakeInput = {} as Datatable;
const metricTrendline = (args: TrendlineArguments) =>
metricTrendlineFunction().fn(fakeInput, args, fakeContext);
describe('metric trendline function', () => {
const tableWithBreakdown: Datatable = {
type: 'datatable',
columns: [
{
id: 'breakdown',
name: 'Top 5 values of extension.keyword',
meta: {
type: 'string',
field: 'extension.keyword',
index: 'kibana_sample_data_logs',
params: {
id: 'terms',
params: {
id: 'string',
otherBucketLabel: 'Other',
missingBucketLabel: '(missing value)',
},
},
source: 'esaggs',
sourceParams: {
hasPrecisionError: false,
indexPatternId: '90943e30-9a47-11e8-b64d-95841ca0b247',
id: '0',
enabled: true,
type: 'terms',
params: {
field: 'extension.keyword',
orderBy: '2',
order: 'desc',
size: 5,
otherBucket: true,
otherBucketLabel: 'Other',
missingBucket: false,
missingBucketLabel: '(missing value)',
includeIsRegex: false,
excludeIsRegex: false,
},
schema: 'segment',
},
},
},
{
id: 'time',
name: 'timestamp per 30 minutes',
meta: {
type: 'date',
field: 'timestamp',
index: 'kibana_sample_data_logs',
params: {
id: 'date',
params: {
pattern: 'HH:mm',
},
},
source: 'esaggs',
sourceParams: {
hasPrecisionError: false,
indexPatternId: '90943e30-9a47-11e8-b64d-95841ca0b247',
appliedTimeRange: {
from: '2022-09-25T16:00:00.000Z',
to: '2022-09-26T16:12:41.742Z',
},
id: '1',
enabled: true,
type: 'date_histogram',
params: {
field: 'timestamp',
timeRange: {
from: '2022-09-25T16:00:00.000Z',
to: '2022-09-26T16:12:41.742Z',
},
useNormalizedEsInterval: true,
extendToTimeRange: true,
scaleMetricValues: false,
interval: 'auto',
used_interval: '30m',
drop_partials: false,
min_doc_count: 0,
extended_bounds: {},
},
schema: 'segment',
},
},
},
{
id: 'metric',
name: 'Median of byts',
meta: {
type: 'number',
field: 'bytes',
index: 'kibana_sample_data_logs',
params: {
id: 'number',
},
source: 'esaggs',
sourceParams: {
hasPrecisionError: false,
indexPatternId: '90943e30-9a47-11e8-b64d-95841ca0b247',
id: '2',
enabled: true,
type: 'median',
params: {
field: 'bytes',
},
schema: 'metric',
},
},
},
],
rows: [
{
breakdown: 'rpm',
time: 1664121600000,
metric: null,
},
{
breakdown: 'rpm',
time: 1664123400000,
metric: null,
},
{
breakdown: 'rpm',
time: 1664125200000,
metric: null,
},
{
breakdown: 'rpm',
time: 1664127000000,
metric: null,
},
{
breakdown: 'deb',
time: 1664121600000,
metric: null,
},
{
breakdown: 'deb',
time: 1664123400000,
metric: 9680,
},
{
breakdown: 'deb',
time: 1664125200000,
metric: null,
},
{
breakdown: 'deb',
time: 1664127000000,
metric: null,
},
{
breakdown: 'zip',
time: 1664121600000,
metric: null,
},
{
breakdown: 'zip',
time: 1664123400000,
metric: null,
},
{
breakdown: 'zip',
time: 1664125200000,
metric: 5037,
},
{
breakdown: 'zip',
time: 1664127000000,
metric: null,
},
{
breakdown: 'zip',
time: 1664128800000,
metric: null,
},
{
breakdown: 'css',
time: 1664121600000,
metric: 3264,
},
{
breakdown: 'css',
time: 1664123400000,
metric: 7215,
},
{
breakdown: 'css',
time: 1664125200000,
metric: 9601,
},
{
breakdown: 'css',
time: 1664127000000,
metric: 8458,
},
{
breakdown: 'gz',
time: 1664121600000,
metric: 3116.5,
},
{
breakdown: 'gz',
time: 1664123400000,
metric: null,
},
{
breakdown: 'gz',
time: 1664125200000,
metric: 4148,
},
{
breakdown: 'gz',
time: 1664127000000,
metric: null,
},
],
meta: {
type: 'esaggs',
source: '90943e30-9a47-11e8-b64d-95841ca0b247',
statistics: {
totalCount: 236,
},
},
};
const tableWithoutBreakdown: Datatable = {
type: 'datatable',
columns: [
{
id: 'time',
name: 'timestamp per 30 minutes',
meta: {
type: 'date',
field: 'timestamp',
index: 'kibana_sample_data_logs',
params: {
id: 'date',
params: {
pattern: 'HH:mm',
},
},
source: 'esaggs',
sourceParams: {
hasPrecisionError: false,
indexPatternId: '90943e30-9a47-11e8-b64d-95841ca0b247',
appliedTimeRange: {
from: '2022-09-25T16:00:00.000Z',
to: '2022-09-26T16:12:41.742Z',
},
id: '1',
enabled: true,
type: 'date_histogram',
params: {
field: 'timestamp',
timeRange: {
from: '2022-09-25T16:00:00.000Z',
to: '2022-09-26T16:12:41.742Z',
},
useNormalizedEsInterval: true,
extendToTimeRange: true,
scaleMetricValues: false,
interval: 'auto',
used_interval: '30m',
drop_partials: false,
min_doc_count: 0,
extended_bounds: {},
},
schema: 'segment',
},
},
},
{
id: 'metric',
name: 'Median of byts',
meta: {
type: 'number',
field: 'bytes',
index: 'kibana_sample_data_logs',
params: {
id: 'number',
},
source: 'esaggs',
sourceParams: {
hasPrecisionError: false,
indexPatternId: '90943e30-9a47-11e8-b64d-95841ca0b247',
id: '2',
enabled: true,
type: 'median',
params: {
field: 'bytes',
},
schema: 'metric',
},
},
},
],
rows: [
{
time: 1664121600000,
metric: null,
},
{
time: 1664123400000,
metric: null,
},
{
time: 1664125200000,
metric: null,
},
{
time: 1664127000000,
metric: null,
},
],
meta: {
type: 'esaggs',
source: '90943e30-9a47-11e8-b64d-95841ca0b247',
statistics: {
totalCount: 236,
},
},
};
it.each(['metric', 'time', 'breakdown'])('checks %s accessor', (accessor) => {
const table = {
...tableWithBreakdown,
columns: tableWithBreakdown.columns.filter((column) => column.id !== accessor),
};
const args = {
table,
metric: 'metric',
timeField: 'time',
breakdownBy: 'breakdown',
inspectorTableId: '',
};
expect(() => metricTrendline(args)).toThrow();
});
it('checks accessors', () => {});
it('builds trends with breakdown', () => {
const { trends } = metricTrendline({
table: tableWithBreakdown,
metric: 'metric',
timeField: 'time',
breakdownBy: 'breakdown',
inspectorTableId: '',
});
expect(trends).toMatchSnapshot();
});
it('builds trends without breakdown', () => {
const { trends } = metricTrendline({
table: tableWithoutBreakdown,
metric: 'metric',
timeField: 'time',
inspectorTableId: '',
});
expect(trends).toMatchSnapshot();
});
it('creates inspector information', () => {
const tableId = 'my-id';
const { inspectorTable, inspectorTableId } = metricTrendline({
table: tableWithBreakdown,
metric: 'metric',
timeField: 'time',
breakdownBy: 'breakdown',
inspectorTableId: tableId,
});
expect(inspectorTableId).toBe(tableId);
expect(inspectorTable).toMatchSnapshot();
});
});

View file

@ -0,0 +1,144 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import {
validateAccessor,
getColumnByAccessor,
prepareLogTable,
Dimension,
} from '@kbn/visualizations-plugin/common/utils';
import { DatatableRow } from '@kbn/expressions-plugin/common';
import { MetricWTrend } from '@elastic/charts';
import type { TrendlineExpressionFunctionDefinition } from '../types';
import { DEFAULT_TRENDLINE_NAME, EXPRESSION_METRIC_TRENDLINE_NAME } from '../constants';
export const metricTrendlineFunction = (): TrendlineExpressionFunctionDefinition => ({
name: EXPRESSION_METRIC_TRENDLINE_NAME,
inputTypes: ['datatable'],
type: EXPRESSION_METRIC_TRENDLINE_NAME,
help: i18n.translate('expressionMetricVis.trendline.function.help', {
defaultMessage: 'Metric visualization',
}),
args: {
metric: {
types: ['vis_dimension', 'string'],
help: i18n.translate('expressionMetricVis.trendline.function.metric.help', {
defaultMessage: 'The primary metric.',
}),
required: true,
},
timeField: {
types: ['vis_dimension', 'string'],
help: i18n.translate('expressionMetricVis.trendline.function.timeField.help', {
defaultMessage: 'The time field for the trend line',
}),
required: true,
},
breakdownBy: {
types: ['vis_dimension', 'string'],
help: i18n.translate('expressionMetricVis.trendline.function.breakdownBy.help', {
defaultMessage: 'The dimension containing the labels for sub-categories.',
}),
},
table: {
types: ['datatable'],
help: i18n.translate('expressionMetricVis.trendline.function.table.help', {
defaultMessage: 'A data table',
}),
multi: false,
},
inspectorTableId: {
types: ['string'],
help: i18n.translate('expressionMetricVis.trendline.function.inspectorTableId.help', {
defaultMessage: 'An ID for the inspector table',
}),
multi: false,
default: 'trendline',
},
},
fn(input, args, handlers) {
const table = args.table;
validateAccessor(args.metric, table.columns);
validateAccessor(args.timeField, table.columns);
validateAccessor(args.breakdownBy, table.columns);
const argsTable: Dimension[] = [
[
[args.metric],
i18n.translate('expressionMetricVis.function.dimension.metric', {
defaultMessage: 'Metric',
}),
],
[
[args.timeField],
i18n.translate('expressionMetricVis.function.dimension.timeField', {
defaultMessage: 'Time field',
}),
],
];
if (args.breakdownBy) {
argsTable.push([
[args.breakdownBy],
i18n.translate('expressionMetricVis.function.dimension.splitGroup', {
defaultMessage: 'Split group',
}),
]);
}
const inspectorTable = prepareLogTable(table, argsTable, true);
const metricColId = getColumnByAccessor(args.metric, table.columns)?.id;
const timeColId = getColumnByAccessor(args.timeField, table.columns)?.id;
if (!metricColId || !timeColId) {
throw new Error("Metric trendline - couldn't find metric or time column!");
}
const trends: Record<string, MetricWTrend['trend']> = {};
if (!args.breakdownBy) {
trends[DEFAULT_TRENDLINE_NAME] = table.rows.map((row) => ({
x: row[timeColId],
y: row[metricColId],
}));
} else {
const breakdownByColId = getColumnByAccessor(args.breakdownBy, table.columns)?.id;
if (!breakdownByColId) {
throw new Error("Metric trendline - couldn't find breakdown column!");
}
const rowsByBreakdown: Record<string, DatatableRow[]> = {};
table.rows.forEach((row) => {
const breakdownTerm = row[breakdownByColId];
if (!(breakdownTerm in rowsByBreakdown)) {
rowsByBreakdown[breakdownTerm] = [];
}
rowsByBreakdown[breakdownTerm].push(row);
});
for (const breakdownTerm in rowsByBreakdown) {
if (!rowsByBreakdown.hasOwnProperty(breakdownTerm)) continue;
trends[breakdownTerm] = rowsByBreakdown[breakdownTerm].map((row) => ({
x: row[timeColId] !== null ? row[timeColId] : NaN,
y: row[metricColId] !== null ? row[metricColId] : NaN,
}));
}
}
return {
type: EXPRESSION_METRIC_TRENDLINE_NAME,
trends,
inspectorTable,
inspectorTableId: args.inspectorTableId,
};
},
});

View file

@ -16,7 +16,7 @@ import {
import { LayoutDirection } from '@elastic/charts';
import { visType } from '../types';
import { MetricVisExpressionFunctionDefinition } from '../types';
import { EXPRESSION_METRIC_NAME } from '../constants';
import { EXPRESSION_METRIC_NAME, EXPRESSION_METRIC_TRENDLINE_NAME } from '../constants';
export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({
name: EXPRESSION_METRIC_NAME,
@ -51,6 +51,12 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({
defaultMessage: 'The dimension containing the labels for sub-categories.',
}),
},
trendline: {
types: [EXPRESSION_METRIC_TRENDLINE_NAME],
help: i18n.translate('expressionMetricVis.function.trendline.help', {
defaultMessage: 'An optional trendline configuration',
}),
},
subtitle: {
types: ['string'],
help: i18n.translate('expressionMetricVis.function.subtitle.help', {
@ -98,6 +104,14 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({
'Specifies the minimum number of tiles in the metric grid regardless of the input data.',
}),
},
inspectorTableId: {
types: ['string'],
help: i18n.translate('expressionMetricVis.function.inspectorTableId.help', {
defaultMessage: 'An ID for the inspector table',
}),
multi: false,
default: 'default',
},
},
fn(input, args, handlers) {
validateAccessor(args.metric, input.columns);
@ -146,7 +160,14 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({
}
const logTable = prepareLogTable(input, argsTable, true);
handlers.inspectorAdapters.tables.logDatatable('default', logTable);
handlers.inspectorAdapters.tables.logDatatable(args.inspectorTableId, logTable);
if (args.trendline?.inspectorTable && args.trendline.inspectorTableId) {
handlers.inspectorAdapters.tables.logDatatable(
args.trendline?.inspectorTableId,
args.trendline?.inspectorTable
);
}
}
return {
@ -164,6 +185,7 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({
progressDirection: args.progressDirection,
maxCols: args.maxCols,
minTiles: args.minTiles,
trends: args.trendline?.trends,
},
dimensions: {
metric: args.metric,

View file

@ -22,4 +22,4 @@ export type {
export { metricVisFunction } from './expression_functions';
export { EXPRESSION_METRIC_NAME } from './constants';
export { EXPRESSION_METRIC_NAME, EXPRESSION_METRIC_TRENDLINE_NAME } from './constants';

View file

@ -7,22 +7,23 @@
*/
import type { PaletteOutput } from '@kbn/coloring';
import { LayoutDirection } from '@elastic/charts';
import { LayoutDirection, MetricWTrend } from '@elastic/charts';
import {
Datatable,
ExpressionFunctionDefinition,
ExpressionValueRender,
} from '@kbn/expressions-plugin/common';
import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common';
import { ExpressionValueVisDimension, prepareLogTable } from '@kbn/visualizations-plugin/common';
import { CustomPaletteState } from '@kbn/charts-plugin/common';
import { VisParams, visType } from './expression_renderers';
import { EXPRESSION_METRIC_NAME } from '../constants';
import { EXPRESSION_METRIC_NAME, EXPRESSION_METRIC_TRENDLINE_NAME } from '../constants';
export interface MetricArguments {
metric: ExpressionValueVisDimension | string;
secondaryMetric?: ExpressionValueVisDimension | string;
max?: ExpressionValueVisDimension | string;
breakdownBy?: ExpressionValueVisDimension | string;
trendline?: TrendlineResult;
subtitle?: string;
secondaryPrefix?: string;
progressDirection: LayoutDirection;
@ -30,6 +31,7 @@ export interface MetricArguments {
palette?: PaletteOutput<CustomPaletteState>;
maxCols: number;
minTiles?: number;
inspectorTableId: string;
}
export type MetricInput = Datatable;
@ -46,3 +48,25 @@ export type MetricVisExpressionFunctionDefinition = ExpressionFunctionDefinition
MetricArguments,
ExpressionValueRender<MetricVisRenderConfig>
>;
export interface TrendlineArguments {
metric: ExpressionValueVisDimension | string;
timeField: ExpressionValueVisDimension | string;
breakdownBy?: ExpressionValueVisDimension | string;
table: Datatable;
inspectorTableId: string;
}
export interface TrendlineResult {
type: typeof EXPRESSION_METRIC_TRENDLINE_NAME;
trends: Record<string, MetricWTrend['trend']>;
inspectorTable: ReturnType<typeof prepareLogTable>;
inspectorTableId: string;
}
export type TrendlineExpressionFunctionDefinition = ExpressionFunctionDefinition<
typeof EXPRESSION_METRIC_TRENDLINE_NAME,
Datatable,
TrendlineArguments,
TrendlineResult
>;

View file

@ -9,6 +9,7 @@
import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common';
import { CustomPaletteState } from '@kbn/charts-plugin/common';
import { LayoutDirection } from '@elastic/charts';
import { TrendlineResult } from './expression_functions';
export const visType = 'metric';
@ -27,6 +28,7 @@ export interface MetricVisParam {
progressDirection: LayoutDirection;
maxCols: number;
minTiles?: number;
trends?: TrendlineResult['trends'];
}
export interface VisParams {

View file

@ -16,6 +16,7 @@ import {
MetricElementEvent,
MetricWNumber,
MetricWProgress,
MetricWTrend,
Settings,
} from '@elastic/charts';
import { SerializedFieldFormat } from '@kbn/field-formats-plugin/common';
@ -25,6 +26,8 @@ import { HtmlAttributes } from 'csstype';
import { CustomPaletteState } from '@kbn/charts-plugin/common/expressions/palette/types';
import { DimensionsVisParam } from '../../common';
import { euiThemeVars } from '@kbn/ui-theme';
import { DEFAULT_TRENDLINE_NAME } from '../../common/constants';
import faker from 'faker';
const mockDeserialize = jest.fn((params) => {
const converter =
@ -367,6 +370,40 @@ describe('MetricVisComponent', function () {
(getConfig(basePriceColumnId, 'horizontal') as MetricWProgress).progressBarDirection
).toBe('horizontal');
});
it('should configure trendline if provided', () => {
const trends = {
[DEFAULT_TRENDLINE_NAME]: [
{ x: 1, y: 2 },
{ x: 3, y: 4 },
{ x: 5, y: 6 },
{ x: 7, y: 8 },
],
};
const tileConfig = shallow(
<MetricVis
config={{
...config,
metric: {
...config.metric,
trends,
},
dimensions: {
...config.dimensions,
breakdownBy: undefined,
},
}}
data={table}
{...defaultProps}
/>
)
.find(Metric)
.props().data![0][0]! as MetricWTrend;
expect(tileConfig.trend).toEqual(trends[DEFAULT_TRENDLINE_NAME]);
expect(tileConfig.trendShape).toEqual('area');
});
});
describe('metric grid', () => {
@ -701,6 +738,74 @@ describe('MetricVisComponent', function () {
]
`);
});
it('should configure trendlines if provided', () => {
const trends: Record<string, MetricWTrend['trend']> = {
Friday: [
{ x: faker.random.number(), y: faker.random.number() },
{ x: faker.random.number(), y: faker.random.number() },
{ x: faker.random.number(), y: faker.random.number() },
{ x: faker.random.number(), y: faker.random.number() },
],
Wednesday: [
{ x: faker.random.number(), y: faker.random.number() },
{ x: faker.random.number(), y: faker.random.number() },
{ x: faker.random.number(), y: faker.random.number() },
{ x: faker.random.number(), y: faker.random.number() },
],
Saturday: [
{ x: faker.random.number(), y: faker.random.number() },
{ x: faker.random.number(), y: faker.random.number() },
{ x: faker.random.number(), y: faker.random.number() },
{ x: faker.random.number(), y: faker.random.number() },
],
Sunday: [
{ x: faker.random.number(), y: faker.random.number() },
{ x: faker.random.number(), y: faker.random.number() },
{ x: faker.random.number(), y: faker.random.number() },
{ x: faker.random.number(), y: faker.random.number() },
],
Thursday: [
{ x: faker.random.number(), y: faker.random.number() },
{ x: faker.random.number(), y: faker.random.number() },
{ x: faker.random.number(), y: faker.random.number() },
{ x: faker.random.number(), y: faker.random.number() },
],
Other: [
{ x: faker.random.number(), y: faker.random.number() },
{ x: faker.random.number(), y: faker.random.number() },
{ x: faker.random.number(), y: faker.random.number() },
{ x: faker.random.number(), y: faker.random.number() },
],
// this one shouldn't show up!
[DEFAULT_TRENDLINE_NAME]: [
{ x: faker.random.number(), y: faker.random.number() },
{ x: faker.random.number(), y: faker.random.number() },
{ x: faker.random.number(), y: faker.random.number() },
{ x: faker.random.number(), y: faker.random.number() },
],
};
const data = shallow(
<MetricVis
config={{
...config,
metric: {
...config.metric,
trends,
},
}}
data={table}
{...defaultProps}
/>
)
.find(Metric)
.props().data![0] as MetricWTrend[];
data?.forEach((tileConfig) => {
expect(tileConfig.trend).toEqual(trends[tileConfig.title!]);
expect(tileConfig.trendShape).toEqual('area');
});
});
it('renders with no data', () => {
const component = shallow(
@ -816,6 +921,44 @@ describe('MetricVisComponent', function () {
expect(renderCompleteSpy).toHaveBeenCalledTimes(1);
});
it('should convert null values to NaN', () => {
const metricId = faker.random.word();
const tableWNull: Datatable = {
type: 'datatable',
columns: [
{
id: metricId,
name: metricId,
meta: {
type: 'number',
},
},
],
rows: [{ [metricId]: null }],
};
const metricConfig = shallow(
<MetricVis
config={{
metric: {
progressDirection: 'vertical',
maxCols: 5,
},
dimensions: {
metric: metricId,
},
}}
data={tableWNull}
{...defaultProps}
/>
)
.find(Metric)
.props().data![0][0]! as MetricWNumber;
expect(metricConfig.value).toBeNaN();
});
describe('filter events', () => {
const fireEventSpy = jest.fn();

View file

@ -18,13 +18,14 @@ import {
isMetricElementEvent,
RenderChangeListener,
Settings,
MetricWTrend,
MetricWNumber,
} from '@elastic/charts';
import { getColumnByAccessor, getFormatByAccessor } from '@kbn/visualizations-plugin/common/utils';
import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common';
import type {
Datatable,
DatatableColumn,
DatatableRow,
IInterpreterRenderHandlers,
RenderMode,
} from '@kbn/expressions-plugin/common';
@ -35,6 +36,7 @@ import { CUSTOM_PALETTE } from '@kbn/coloring';
import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme';
import { useResizeObserver } from '@elastic/eui';
import { DEFAULT_TRENDLINE_NAME } from '../../common/constants';
import { VisParams } from '../../common';
import {
getPaletteService,
@ -222,28 +224,20 @@ export const MetricVis = ({
.getConverterFor('text');
}
let getProgressBarConfig = (_row: DatatableRow): Partial<MetricWProgress> => ({});
const maxColId = config.dimensions.max
? getColumnByAccessor(config.dimensions.max, data.columns)?.id
: undefined;
if (maxColId) {
getProgressBarConfig = (_row: DatatableRow): Partial<MetricWProgress> => ({
domainMax: _row[maxColId],
progressBarDirection: config.metric.progressDirection,
});
}
const metricConfigs: MetricSpec['data'][number] = (
breakdownByColumn ? data.rows : data.rows.slice(0, 1)
).map((row, rowIdx) => {
const value = row[primaryMetricColumn.id];
const value: number = row[primaryMetricColumn.id] !== null ? row[primaryMetricColumn.id] : NaN;
const title = breakdownByColumn
? formatBreakdownValue(row[breakdownByColumn.id])
: primaryMetricColumn.name;
const subtitle = breakdownByColumn ? primaryMetricColumn.name : config.metric.subtitle;
const secondaryPrefix = config.metric.secondaryPrefix ?? secondaryMetricColumn?.name;
return {
const baseMetric: MetricWNumber = {
value,
valueFormatter: formatPrimaryMetric,
title,
@ -272,8 +266,39 @@ export const MetricVis = ({
rowIdx
) ?? defaultColor
: config.metric.color ?? defaultColor,
...getProgressBarConfig(row),
};
const trendId = breakdownByColumn ? row[breakdownByColumn.id] : DEFAULT_TRENDLINE_NAME;
if (config.metric.trends && config.metric.trends[trendId]) {
const metricWTrend: MetricWTrend = {
...baseMetric,
trend: config.metric.trends[trendId],
trendShape: 'area',
trendA11yTitle: i18n.translate('expressionMetricVis.trendA11yTitle', {
defaultMessage: '{dataTitle} over time.',
values: {
dataTitle: primaryMetricColumn.name,
},
}),
trendA11yDescription: i18n.translate('expressionMetricVis.trendA11yDescription', {
defaultMessage: 'A line chart showing the trend of the primary metric over time.',
}),
};
return metricWTrend;
}
if (maxColId && config.metric.progressDirection) {
const metricWProgress: MetricWProgress = {
...baseMetric,
domainMax: row[maxColId],
progressBarDirection: config.metric.progressDirection,
};
return metricWProgress;
}
return baseMetric;
});
if (config.metric.minTiles) {

View file

@ -13,3 +13,4 @@ export function plugin() {
}
export { getDataBoundsForPalette } from './utils';
export { EXPRESSION_METRIC_NAME, EXPRESSION_METRIC_TRENDLINE_NAME } from '../common';

View file

@ -17,6 +17,7 @@ import { setFormatService, setPaletteService } from './services';
import { getMetricVisRenderer } from './expression_renderers';
import { setThemeService } from './services/theme_service';
import { setUiSettingsService } from './services/ui_settings';
import { metricTrendlineFunction } from '../common/expression_functions/metric_trendline_function';
/** @internal */
export interface ExpressionMetricPluginSetup {
@ -45,6 +46,7 @@ export class ExpressionMetricPlugin implements Plugin {
});
expressions.registerFunction(metricVisFunction);
expressions.registerFunction(metricTrendlineFunction);
expressions.registerRenderer(getMetricVisRenderer({ getStartDeps }));
setUiSettingsService(core.uiSettings);

View file

@ -8,6 +8,7 @@
import { ColorSchemas } from '@kbn/charts-plugin/common';
import { CustomPaletteParams, PaletteOutput } from '@kbn/coloring';
import { CollapseFunction } from '@kbn/visualizations-plugin/common';
import { GaugeVisParams } from '../../types';
import { getConfiguration } from './goal';
@ -69,7 +70,10 @@ describe('getConfiguration', () => {
buckets,
maxAccessor,
columnsWithoutReferenced: [],
bucketCollapseFn: { [collapseFn]: [breakdownByAccessor] },
bucketCollapseFn: { [collapseFn]: [breakdownByAccessor] } as Record<
CollapseFunction,
string[]
>,
})
).toEqual({
breakdownByAccessor,
@ -78,6 +82,7 @@ describe('getConfiguration', () => {
layerType: 'data',
maxAccessor,
metricAccessor,
showBar: true,
palette,
});
});

View file

@ -7,7 +7,11 @@
*/
import { CustomPaletteParams, PaletteOutput } from '@kbn/coloring';
import { Column, MetricVisConfiguration } from '@kbn/visualizations-plugin/common';
import {
CollapseFunction,
Column,
MetricVisConfiguration,
} from '@kbn/visualizations-plugin/common';
import { GaugeVisParams } from '../../types';
export const getConfiguration = (
@ -28,15 +32,15 @@ export const getConfiguration = (
};
maxAccessor: string;
columnsWithoutReferenced: Column[];
bucketCollapseFn?: Record<string, string[]>;
bucketCollapseFn?: Record<CollapseFunction, string[]>;
}
): MetricVisConfiguration => {
const [metricAccessor] = metrics;
const [breakdownByAccessor] = buckets.all;
const collapseFn = bucketCollapseFn
? Object.keys(bucketCollapseFn).find((key) =>
bucketCollapseFn[key].includes(breakdownByAccessor)
)
? (Object.keys(bucketCollapseFn).find((key) =>
bucketCollapseFn[key as CollapseFunction].includes(breakdownByAccessor)
) as CollapseFunction)
: undefined;
return {
layerId,
@ -45,6 +49,7 @@ export const getConfiguration = (
metricAccessor,
breakdownByAccessor,
maxAccessor,
showBar: Boolean(maxAccessor),
collapseFn,
subtitle: gauge.labels.show && gauge.style.subText ? gauge.style.subText : undefined,
};

View file

@ -8,6 +8,7 @@
import { ColorSchemas } from '@kbn/charts-plugin/common';
import { CustomPaletteParams, PaletteOutput } from '@kbn/coloring';
import { CollapseFunction } from '@kbn/visualizations-plugin/common';
import { getConfiguration } from '.';
import { VisParams } from '../../types';
@ -50,7 +51,7 @@ describe('getConfiguration', () => {
metrics: [metric],
buckets: { all: [bucket], customBuckets: { metric: bucket } },
columnsWithoutReferenced: [],
bucketCollapseFn: { [collapseFn]: [bucket] },
bucketCollapseFn: { [collapseFn]: [bucket] } as Record<CollapseFunction, string[]>,
})
).toEqual({
breakdownByAccessor: bucket,

View file

@ -7,7 +7,11 @@
*/
import { CustomPaletteParams, PaletteOutput } from '@kbn/coloring';
import { Column, MetricVisConfiguration } from '@kbn/visualizations-plugin/common';
import {
CollapseFunction,
Column,
MetricVisConfiguration,
} from '@kbn/visualizations-plugin/common';
import { VisParams } from '../../types';
export const getConfiguration = (
@ -26,15 +30,15 @@ export const getConfiguration = (
customBuckets: Record<string, string>;
};
columnsWithoutReferenced: Column[];
bucketCollapseFn?: Record<string, string[]>;
bucketCollapseFn?: Record<CollapseFunction, string[]>;
}
): MetricVisConfiguration => {
const [metricAccessor] = metrics;
const [breakdownByAccessor] = buckets.all;
const collapseFn = bucketCollapseFn
? Object.keys(bucketCollapseFn).find((key) =>
bucketCollapseFn[key].includes(breakdownByAccessor)
)
? (Object.keys(bucketCollapseFn).find((key) =>
bucketCollapseFn[key as CollapseFunction].includes(breakdownByAccessor)
) as CollapseFunction)
: undefined;
return {
layerId,

View file

@ -8,6 +8,7 @@
import { AggTypes } from '../../../common';
import { getConfiguration } from '.';
import { CollapseFunction } from '@kbn/visualizations-plugin/common';
const params = {
perPage: 20,
@ -48,7 +49,7 @@ describe('getConfiguration', () => {
},
},
],
bucketCollapseFn: { sum: ['bucket-1'] },
bucketCollapseFn: { sum: ['bucket-1'] } as Record<CollapseFunction, string[]>,
})
).toEqual({
columns: [

View file

@ -6,19 +6,26 @@
* Side Public License, v 1.
*/
import { Column, PagingState, TableVisConfiguration } from '@kbn/visualizations-plugin/common';
import {
CollapseFunction,
Column,
PagingState,
TableVisConfiguration,
} from '@kbn/visualizations-plugin/common';
import { TableVisParams } from '../../../common';
const getColumns = (
params: TableVisParams,
metrics: string[],
columns: Column[],
bucketCollapseFn?: Record<string, string[]>
bucketCollapseFn?: Record<CollapseFunction, string[]>
) => {
const { showTotal, totalFunc } = params;
return columns.map(({ columnId }) => {
const collapseFn = bucketCollapseFn
? Object.keys(bucketCollapseFn).find((key) => bucketCollapseFn[key].includes(columnId))
? (Object.keys(bucketCollapseFn).find((key) =>
bucketCollapseFn[key as CollapseFunction].includes(columnId)
) as CollapseFunction)
: undefined;
return {
columnId,
@ -61,7 +68,7 @@ export const getConfiguration = (
customBuckets: Record<string, string>;
};
columnsWithoutReferenced: Column[];
bucketCollapseFn?: Record<string, string[]>;
bucketCollapseFn?: Record<CollapseFunction, string[]>;
}
): TableVisConfiguration => {
return {

View file

@ -5,9 +5,10 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { CollapseFunction } from '@kbn/visualizations-plugin/common';
import type { Metric } from '../../../../common/types';
const functionMap: Partial<Record<string, string>> = {
const functionMap: Partial<Record<string, CollapseFunction>> = {
mean: 'avg',
min: 'min',
max: 'max',

View file

@ -7,7 +7,7 @@
*/
import { METRIC_TYPES } from '@kbn/data-plugin/public';
import { Column, ColumnWithMeta } from '@kbn/visualizations-plugin/common';
import { CollapseFunction, Column, ColumnWithMeta } from '@kbn/visualizations-plugin/common';
import {
convertToLensModule,
getVisSchemas,
@ -25,7 +25,7 @@ export interface Layer {
columnOrder: never[];
seriesIdsMap: Record<string, string>;
isReferenceLineLayer: boolean;
collapseFn?: string;
collapseFn?: CollapseFunction;
}
const SIBBLING_PIPELINE_AGGS: string[] = [
@ -175,9 +175,11 @@ export const convertToLens: ConvertXYToLensVisualization = async (vis, timefilte
}
});
const collapseFn = l.bucketCollapseFn
? Object.keys(l.bucketCollapseFn).find((key) =>
l.bucketCollapseFn[key].includes(l.buckets.customBuckets[l.metrics[0]])
)
? (Object.keys(l.bucketCollapseFn).find((key) =>
l.bucketCollapseFn[key as CollapseFunction].includes(
l.buckets.customBuckets[l.metrics[0]]
)
) as CollapseFunction)
: undefined;
return {
indexPatternId,

View file

@ -134,3 +134,5 @@ export const GaugeColorModes = {
PALETTE: 'palette',
NONE: 'none',
} as const;
export const CollapseFunctions = ['sum', 'avg', 'min', 'max'] as const;

View file

@ -26,8 +26,11 @@ import {
GaugeLabelMajorModes,
GaugeColorModes,
GaugeCentralMajorModes,
CollapseFunctions,
} from '../constants';
export type CollapseFunction = typeof CollapseFunctions[number];
export type FillType = $Values<typeof FillTypes>;
export type SeriesType = $Values<typeof SeriesTypes>;
export type YAxisMode = $Values<typeof YAxisModes>;
@ -72,7 +75,7 @@ export interface XYDataLayerConfig {
yConfig?: YConfig[];
splitAccessor?: string;
palette?: PaletteOutput;
collapseFn?: string;
collapseFn?: CollapseFunction;
xScaleType?: 'time' | 'linear' | 'ordinal';
isHistogram?: boolean;
columnToLabel?: string;
@ -179,7 +182,7 @@ export interface ColumnState {
columnId: string;
summaryRow?: 'none' | 'sum' | 'avg' | 'count' | 'min' | 'max';
alignment?: 'left' | 'right' | 'center';
collapseFn?: string;
collapseFn?: CollapseFunction;
}
export interface TableVisConfiguration {
@ -203,10 +206,11 @@ export interface MetricVisConfiguration {
breakdownByAccessor?: string;
// the dimensions can optionally be single numbers
// computed by collapsing all rows
collapseFn?: string;
collapseFn?: CollapseFunction;
subtitle?: string;
secondaryPrefix?: string;
progressDirection?: LayoutDirection;
showBar?: boolean;
color?: string;
palette?: PaletteOutput<CustomPaletteParams>;
maxCols?: number;
@ -218,7 +222,7 @@ export interface PartitionLayerState {
primaryGroups: string[];
secondaryGroups?: string[];
metric?: string;
collapseFns?: Record<string, string>;
collapseFns?: Record<string, CollapseFunction>;
numberDisplay: NumberDisplayType;
categoryDisplay: CategoryDisplayType;
legendDisplay: LegendDisplayType;

View file

@ -7,8 +7,9 @@
*/
import type { DataViewField } from '@kbn/data-views-plugin/common';
import { CollapseFunctions } from './constants';
import type { SupportedMetric } from './lib/convert/supported_metrics';
import type { Layer, XYAnnotationsLayerConfig, XYLayerConfig } from './types';
import type { CollapseFunction, Layer, XYAnnotationsLayerConfig, XYLayerConfig } from './types';
export const isAnnotationsLayer = (
layer: Pick<XYLayerConfig, 'layerType'>
@ -31,3 +32,6 @@ export const isFieldValid = (
return true;
};
export const isCollapseFunction = (candidate: string | undefined): candidate is CollapseFunction =>
Boolean(candidate && CollapseFunctions.includes(candidate as CollapseFunction));

View file

@ -8,7 +8,13 @@
import type { DataView } from '@kbn/data-views-plugin/common';
import { IAggConfig, METRIC_TYPES } from '@kbn/data-plugin/public';
import { AggBasedColumn, SchemaConfig, SupportedAggregation } from '../../common';
import {
AggBasedColumn,
CollapseFunction,
isCollapseFunction,
SchemaConfig,
SupportedAggregation,
} from '../../common';
import { convertBucketToColumns } from '../../common/convert_to_lens/lib/buckets';
import { isSiblingPipeline } from '../../common/convert_to_lens/lib/utils';
import { BucketColumn } from '../../common/convert_to_lens/lib';
@ -30,7 +36,7 @@ export const getBucketCollapseFn = (
customBucketsMap: Record<string, string>,
metricColumns: AggBasedColumn[]
) => {
const collapseFnMap: Record<string, string[]> = {
const collapseFnMap: Record<CollapseFunction, string[]> = {
min: [],
max: [],
sum: [],
@ -45,8 +51,11 @@ export const getBucketCollapseFn = (
const collapseFn = metrics
.find((m) => m.aggId === metricColumn.meta.aggId)
?.aggType.split('_')[0];
if (collapseFn) {
collapseFnMap[collapseFn].push(bucket.columnId);
if (isCollapseFunction(collapseFn)) {
if (collapseFn) {
collapseFnMap[collapseFn].push(bucket.columnId);
}
}
});
});

View file

@ -217,6 +217,27 @@ export class InspectorService extends FtrService {
await this.openInspectorView('Requests');
}
/**
* Check how many tables are being shown in the inspector.
* @returns
*/
public async getNumberOfTables(): Promise<number> {
const chooserDataTestId = 'inspectorTableChooser';
const menuDataTestId = 'inspectorTableChooserMenuPanel';
if (!(await this.testSubjects.exists(chooserDataTestId))) {
return 1;
}
return await this.retry.try(async () => {
await this.testSubjects.click(chooserDataTestId);
const menu = await this.testSubjects.find(menuDataTestId);
return (
await menu.findAllByCssSelector(`[data-test-subj="${menuDataTestId}"] .euiContextMenuItem`)
).length;
});
}
/**
* Returns the selected option value from combobox
*/

View file

@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n';
import type { CollapseExpressionFunction } from './types';
type CollapseFunction = 'sum' | 'avg' | 'min' | 'max';
export type CollapseFunction = 'sum' | 'avg' | 'min' | 'max';
export interface CollapseArgs {
by?: string[];
metric?: string[];

View file

@ -10,6 +10,7 @@ import type { PaletteOutput, CustomPaletteParams } from '@kbn/coloring';
import type { CustomPaletteState } from '@kbn/charts-plugin/common';
import type { ExpressionFunctionDefinition, DatatableColumn } from '@kbn/expressions-plugin/common';
import type { SortingHint } from '../..';
import { CollapseFunction } from '../collapse';
export type LensGridDirection = 'none' | Direction;
@ -43,7 +44,7 @@ export interface ColumnState {
colorMode?: 'none' | 'cell' | 'text';
summaryRow?: 'none' | 'sum' | 'avg' | 'count' | 'min' | 'max';
summaryLabel?: string;
collapseFn?: string;
collapseFn?: CollapseFunction;
}
export type DatatableColumnResult = ColumnState & { type: 'lens_datatable_column' };

View file

@ -0,0 +1,13 @@
/*
* 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.
*/
export const layerTypes = {
DATA: 'data',
REFERENCELINE: 'referenceLine',
ANNOTATIONS: 'annotations',
METRIC_TRENDLINE: 'metricTrendline',
} as const;

View file

@ -11,9 +11,10 @@ import type { $Values } from '@kbn/utility-types';
import type { CustomPaletteParams, PaletteOutput } from '@kbn/coloring';
import type { IFieldFormat, SerializedFieldFormat } from '@kbn/field-formats-plugin/common';
import type { ColorMode } from '@kbn/charts-plugin/common';
import { LayerTypes } from '@kbn/expression-xy-plugin/common';
import type { LegendSize } from '@kbn/visualizations-plugin/common';
import { CategoryDisplay, LegendDisplay, NumberDisplay, PieChartTypes } from './constants';
import { layerTypes } from './layer_types';
import { CollapseFunction } from './expressions';
export type { OriginalColumn } from './expressions/map_to_columns';
@ -39,7 +40,7 @@ export interface PersistableFilter extends Filter {
export type SortingHint = 'version';
export type LayerType = typeof LayerTypes[keyof typeof LayerTypes];
export type LayerType = typeof layerTypes[keyof typeof layerTypes];
export type ValueLabelConfig = 'hide' | 'show';
@ -59,7 +60,7 @@ export interface SharedPieLayerState {
primaryGroups: string[];
secondaryGroups?: string[];
metric?: string;
collapseFns?: Record<string, string>;
collapseFns?: Record<string, CollapseFunction>;
numberDisplay: NumberDisplayType;
categoryDisplay: CategoryDisplayType;
legendDisplay: LegendDisplayType;

View file

@ -75,6 +75,7 @@ describe('getLayerMetaInfo', () => {
isStaticValue: false,
sortingHint: undefined,
hasTimeShift: true,
hasReducedTimeRange: true,
})),
getTableSpec: jest.fn(),
getVisualDefaults: jest.fn(),
@ -82,6 +83,7 @@ describe('getLayerMetaInfo', () => {
getMaxPossibleNumValues: jest.fn(),
getFilters: jest.fn(),
isTextBasedLanguage: jest.fn(() => false),
hasDefaultTimeField: jest.fn(() => true),
};
mockDatasource.getPublicAPI.mockReturnValue(updatedPublicAPI);
expect(
@ -101,6 +103,7 @@ describe('getLayerMetaInfo', () => {
getMaxPossibleNumValues: jest.fn(),
getFilters: jest.fn(() => ({ error: 'filters error' })),
isTextBasedLanguage: jest.fn(() => false),
hasDefaultTimeField: jest.fn(() => true),
};
mockDatasource.getPublicAPI.mockReturnValue(updatedPublicAPI);
expect(
@ -128,6 +131,7 @@ describe('getLayerMetaInfo', () => {
getMaxPossibleNumValues: jest.fn(),
getFilters: jest.fn(() => ({ error: 'filters error' })),
isTextBasedLanguage: jest.fn(() => false),
hasDefaultTimeField: jest.fn(() => true),
};
mockDatasource.getPublicAPI.mockReturnValue(updatedPublicAPI);
// both capabilities should be enabled to enable discover
@ -180,6 +184,7 @@ describe('getLayerMetaInfo', () => {
},
disabled: { kuery: [], lucene: [] },
})),
hasDefaultTimeField: jest.fn(() => true),
};
mockDatasource.getPublicAPI.mockReturnValue(updatedPublicAPI);
const { error, meta } = getLayerMetaInfo(

View file

@ -88,7 +88,7 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
target: mockedDndOperations.notFiltering,
state,
setState,
dimensionGroups: [],
targetLayerDimensionGroups: [],
indexPatterns: mockDataViews(),
};
@ -285,7 +285,7 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
onDrop({
...defaultProps,
source: mockedDraggedField,
dimensionGroups,
targetLayerDimensionGroups: dimensionGroups,
dropType: 'field_add',
target: {
...defaultProps.target,
@ -768,8 +768,9 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
...state.layers,
first: {
...state.layers.first,
columnOrder: ['col2'],
columnOrder: ['col1', 'col2'],
columns: {
col1: state.layers.first.columns.col1,
col2: state.layers.first.columns.col1,
},
},
@ -803,10 +804,10 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
...testState.layers,
first: {
...testState.layers.first,
incompleteColumns: {},
columnOrder: ['col1', 'col3'],
columnOrder: ['col1', 'col2', 'col3'],
columns: {
col1: testState.layers.first.columns.col2,
col2: testState.layers.first.columns.col2,
col3: testState.layers.first.columns.col3,
},
},
@ -879,7 +880,7 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
},
source: mockedDndOperations.bucket,
state: testState,
dimensionGroups,
targetLayerDimensionGroups: dimensionGroups,
dropType: 'move_compatible',
});
@ -890,11 +891,11 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
...testState.layers,
first: {
...testState.layers.first,
incompleteColumns: {},
columnOrder: ['newCol', 'col1', 'col3', 'col4'],
columnOrder: ['newCol', 'col1', 'col2', 'col3', 'col4'],
columns: {
newCol: testState.layers.first.columns.col2,
col1: testState.layers.first.columns.col1,
col2: testState.layers.first.columns.col2,
col3: testState.layers.first.columns.col3,
col4: testState.layers.first.columns.col4,
},
@ -918,7 +919,7 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
},
source: mockedDndOperations.bucket,
state: testState,
dimensionGroups,
targetLayerDimensionGroups: dimensionGroups,
dropType: 'duplicate_compatible',
});
@ -957,7 +958,7 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
},
source: mockedDndOperations.bucket2,
state: testState,
dimensionGroups: [
targetLayerDimensionGroups: [
{ ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] },
{ ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] },
{ ...dimensionGroups[2] },
@ -972,11 +973,11 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
...testState.layers,
first: {
...testState.layers.first,
incompleteColumns: {},
columnOrder: ['col1', 'col2', 'col4'],
columnOrder: ['col1', 'col2', 'col3', 'col4'],
columns: {
col1: testState.layers.first.columns.col3,
col2: testState.layers.first.columns.col2,
col3: testState.layers.first.columns.col3,
col4: testState.layers.first.columns.col4,
},
},
@ -1028,7 +1029,7 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
},
},
},
dimensionGroups: [
targetLayerDimensionGroups: [
{ ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] },
{ ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] },
{ ...dimensionGroups[2] },
@ -1043,11 +1044,11 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
...testState.layers,
first: {
...testState.layers.first,
incompleteColumns: {},
columnOrder: ['col1', 'col2', 'col4', 'col5', 'col6'],
columnOrder: ['col1', 'col2', 'col3', 'col4', 'col5', 'col6'],
columns: {
col1: testState.layers.first.columns.col3,
col2: testState.layers.first.columns.col2,
col3: testState.layers.first.columns.col3,
col4: testState.layers.first.columns.col4,
col5: expect.objectContaining({
dataType: 'number',
@ -1085,7 +1086,7 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
columnId: 'col1',
groupId: 'a',
},
dimensionGroups: [
targetLayerDimensionGroups: [
{ ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] },
{ ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] },
{ ...dimensionGroups[2] },
@ -1128,7 +1129,7 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
columnId: 'newCol',
groupId: 'b',
},
dimensionGroups: [
targetLayerDimensionGroups: [
{ ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] },
{ ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] },
{ ...dimensionGroups[2] },
@ -1142,9 +1143,9 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
...testState.layers,
first: {
...testState.layers.first,
incompleteColumns: {},
columnOrder: ['col2', 'col3', 'newCol', 'col4'],
columnOrder: ['col1', 'col2', 'col3', 'newCol', 'col4'],
columns: {
col1: testState.layers.first.columns.col1,
newCol: testState.layers.first.columns.col1,
col2: testState.layers.first.columns.col2,
col3: testState.layers.first.columns.col3,
@ -1171,7 +1172,7 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
},
source: mockedDndOperations.metric,
state: testState,
dimensionGroups: [
targetLayerDimensionGroups: [
{ ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] },
{ ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] },
{ ...dimensionGroups[2] },
@ -1214,7 +1215,7 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
groupId: 'a',
filterOperations: (op: OperationMetadata) => op.dataType === 'number',
},
dimensionGroups: [
targetLayerDimensionGroups: [
// a and b are ordered in reverse visually, but nesting order keeps them in place for column order
{ ...dimensionGroups[1], nestingOrder: 1 },
{ ...dimensionGroups[0], nestingOrder: 0 },
@ -1264,7 +1265,7 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
columnId: 'newCol',
groupId: 'a',
},
dimensionGroups: [
targetLayerDimensionGroups: [
{ ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] },
{ ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] },
{ ...dimensionGroups[2] },
@ -1278,7 +1279,7 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
...testState.layers,
first: {
...testState.layers.first,
columnOrder: ['col1', 'newCol', 'col2', 'col3'],
columnOrder: ['col1', 'newCol', 'col2', 'col3', 'col4'],
columns: {
col1: testState.layers.first.columns.col1,
newCol: expect.objectContaining({
@ -1287,6 +1288,7 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
}),
col2: testState.layers.first.columns.col2,
col3: testState.layers.first.columns.col3,
col4: testState.layers.first.columns.col4,
},
incompleteColumns: {},
},
@ -1311,7 +1313,7 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
columnId: 'newCol',
groupId: 'a',
},
dimensionGroups: [
targetLayerDimensionGroups: [
{ ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] },
{ ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] },
{ ...dimensionGroups[2] },
@ -1359,7 +1361,7 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
columnId: 'col2',
groupId: 'b',
},
dimensionGroups: [
targetLayerDimensionGroups: [
{ ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] },
{ ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] },
{ ...dimensionGroups[2] },
@ -1373,7 +1375,7 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
...testState.layers,
first: {
...testState.layers.first,
columnOrder: ['col1', 'col2', 'col3'],
columnOrder: ['col1', 'col2', 'col3', 'col4'],
columns: {
col1: testState.layers.first.columns.col1,
col2: {
@ -1392,6 +1394,13 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
},
},
col3: testState.layers.first.columns.col3,
col4: {
dataType: 'number',
isBucketed: false,
label: 'Median of bytes',
operationType: 'median',
sourceField: 'bytes',
},
},
incompleteColumns: {},
},
@ -1416,7 +1425,7 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
source: mockedDndOperations.metricC,
dropType: 'swap_compatible',
state: testState,
dimensionGroups: [
targetLayerDimensionGroups: [
{ ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] },
{ ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] },
{ ...dimensionGroups[2] },
@ -1459,7 +1468,7 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
dropType: 'swap_incompatible',
source: mockedDndOperations.metricC,
state: testState,
dimensionGroups: [
targetLayerDimensionGroups: [
{ ...dimensionGroups[0], accessors: [{ columnId: 'col1' }] },
{ ...dimensionGroups[1], accessors: [{ columnId: 'col2' }, { columnId: 'col3' }] },
{ ...dimensionGroups[2] },
@ -1575,7 +1584,7 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
layerId: 'second',
indexPatternId: 'indexPattern1',
},
dimensionGroups: defaultDimensionGroups,
targetLayerDimensionGroups: defaultDimensionGroups,
dropType: 'move_compatible',
};
jest.clearAllMocks();
@ -1592,10 +1601,6 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
...props.state,
layers: {
...props.state.layers,
first: {
...mockedLayers.emptyLayer(),
incompleteColumns: {},
},
second: {
columnOrder: ['col2', 'col3', 'col4', 'newCol', 'col5'],
columns: {
@ -1677,10 +1682,6 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
...props.state,
layers: {
...props.state.layers,
first: {
...mockedLayers.emptyLayer(),
incompleteColumns: {},
},
second: {
columnOrder: ['col2', 'col3', 'col4', 'col5'],
columns: {
@ -1781,10 +1782,6 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
...props.state,
layers: {
...props.state.layers,
first: {
...mockedLayers.emptyLayer(),
incompleteColumns: {},
},
second: {
columnOrder: ['col2', 'col3', 'col4', 'col5'],
columns: {
@ -1826,10 +1823,6 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
...props.state,
layers: {
...props.state.layers,
first: {
...mockedLayers.emptyLayer(),
incompleteColumns: {},
},
second: {
columnOrder: ['col2', 'col3', 'col4', 'col5', 'newCol'],
columns: {
@ -1993,9 +1986,7 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
expect(props.setState).toBeCalledTimes(1);
expect(props.setState).toHaveBeenCalledWith({
...props.state,
layers: {
...props.state.layers,
first: { ...mockedLayers.emptyLayer(), incompleteColumns: {} },
layers: expect.objectContaining({
second: {
...props.state.layers.second,
incompleteColumns: {},
@ -2021,7 +2012,7 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
},
},
},
},
}),
});
});
it('combine_incompatible: allows dropping to combine to multiterms', () => {
@ -2058,9 +2049,7 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
expect(props.setState).toBeCalledTimes(1);
expect(props.setState).toHaveBeenCalledWith({
...props.state,
layers: {
...props.state.layers,
first: { ...mockedLayers.emptyLayer(), incompleteColumns: {} },
layers: expect.objectContaining({
second: {
...props.state.layers.second,
incompleteColumns: {},
@ -2086,7 +2075,7 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
},
},
},
},
}),
});
});
});
@ -2094,7 +2083,7 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
let props: DatasourceDimensionDropHandlerProps<FormBasedPrivateState>;
beforeEach(() => {
props = {
dimensionGroups: defaultDimensionGroups,
targetLayerDimensionGroups: defaultDimensionGroups,
setState: jest.fn(),
dropType: 'move_compatible',
@ -2181,10 +2170,6 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
...props.state,
layers: {
...props.state.layers,
first: {
...mockedLayers.emptyLayer(),
incompleteColumns: {},
},
second: {
columnOrder: ['second', 'secondX0', 'newColumnX0', 'newColumn'],
columns: {
@ -2240,10 +2225,6 @@ describe('FormBasedDimensionEditorPanel: onDrop', () => {
...props.state,
layers: {
...props.state.layers,
first: {
...mockedLayers.emptyLayer(),
incompleteColumns: {},
},
second: {
columnOrder: ['second', 'secondX0'],
columns: {

View file

@ -17,14 +17,12 @@ import {
} from '../../../../types';
import {
insertOrReplaceColumn,
deleteColumn,
getColumnOrder,
reorderByGroups,
copyColumn,
hasOperationSupportForMultipleFields,
getOperationHelperForMultipleFields,
replaceColumn,
deleteColumnInLayers,
} from '../../operations';
import { mergeLayer, mergeLayers } from '../../state_helpers';
import { getNewOperation, getField } from './get_drop_props';
@ -39,7 +37,7 @@ interface DropHandlerProps<T = DataViewDragDropOperation> {
forceRender?: boolean;
}
>;
dimensionGroups: VisualizationDimensionGroupConfig[];
targetLayerDimensionGroups: VisualizationDimensionGroupConfig[];
dropType?: DropType;
source: T;
target: DataViewDragDropOperation;
@ -89,16 +87,24 @@ export function onDrop(props: DatasourceDimensionDropHandlerProps<FormBasedPriva
return onReorder(operationProps);
}
if (['move_compatible', 'replace_compatible'].includes(dropType)) {
return onMoveCompatible(operationProps, true);
}
if (['duplicate_compatible', 'replace_duplicate_compatible'].includes(dropType)) {
if (
[
'duplicate_compatible',
'replace_duplicate_compatible',
'move_compatible',
'replace_compatible',
].includes(dropType)
) {
return onMoveCompatible(operationProps);
}
if (['move_incompatible', 'replace_incompatible'].includes(dropType)) {
return onMoveIncompatible(operationProps, true);
}
if (['duplicate_incompatible', 'replace_duplicate_incompatible'].includes(dropType)) {
if (
[
'duplicate_incompatible',
'replace_duplicate_incompatible',
'move_incompatible',
'replace_incompatible',
].includes(dropType)
) {
return onMoveIncompatible(operationProps);
}
if (dropType === 'swap_compatible') {
@ -116,9 +122,9 @@ const isFieldDropType = (dropType: DropType) =>
['field_add', 'field_replace', 'field_combine'].includes(dropType);
function onFieldDrop(props: DropHandlerProps<DraggedField>, shouldAddField?: boolean) {
const { setState, state, source, target, dimensionGroups, indexPatterns } = props;
const { setState, state, source, target, targetLayerDimensionGroups, indexPatterns } = props;
const prioritizedOperation = dimensionGroups.find(
const prioritizedOperation = targetLayerDimensionGroups.find(
(g) => g.groupId === target.groupId
)?.prioritizedOperation;
@ -168,7 +174,7 @@ function onFieldDrop(props: DropHandlerProps<DraggedField>, shouldAddField?: boo
indexPattern,
op: newOperation,
field,
visualizationGroups: dimensionGroups,
visualizationGroups: targetLayerDimensionGroups,
targetGroup: target.groupId,
shouldCombineField: shouldAddField,
initialParams,
@ -177,45 +183,42 @@ function onFieldDrop(props: DropHandlerProps<DraggedField>, shouldAddField?: boo
return true;
}
function onMoveCompatible(
{ setState, state, source, target, dimensionGroups }: DropHandlerProps<DataViewDragDropOperation>,
shouldDeleteSource?: boolean
) {
const modifiedLayers = copyColumn({
function onMoveCompatible({
setState,
state,
source,
target,
targetLayerDimensionGroups,
}: DropHandlerProps<DataViewDragDropOperation>) {
let modifiedLayers = copyColumn({
layers: state.layers,
target,
source,
shouldDeleteSource,
});
if (target.layerId === source.layerId) {
const updatedColumnOrder = reorderByGroups(
dimensionGroups,
getColumnOrder(modifiedLayers[target.layerId]),
target.groupId,
target.columnId
);
const updatedColumnOrder = reorderByGroups(
targetLayerDimensionGroups,
getColumnOrder(modifiedLayers[target.layerId]),
target.groupId,
target.columnId
);
const newLayer = {
modifiedLayers = {
...modifiedLayers,
[target.layerId]: {
...modifiedLayers[target.layerId],
columnOrder: updatedColumnOrder,
columns: modifiedLayers[target.layerId].columns,
};
},
};
// Time to replace
setState(
mergeLayer({
state,
layerId: target.layerId,
newLayer,
})
);
return true;
} else {
setState(mergeLayers({ state, newLayers: modifiedLayers }));
return true;
}
setState(
mergeLayers({
state,
newLayers: modifiedLayers,
})
);
return true;
}
function onReorder({
@ -250,17 +253,14 @@ function onReorder({
return true;
}
function onMoveIncompatible(
{
setState,
state,
source,
dimensionGroups,
target,
indexPatterns,
}: DropHandlerProps<DataViewDragDropOperation>,
shouldDeleteSource?: boolean
) {
function onMoveIncompatible({
setState,
state,
source,
targetLayerDimensionGroups,
target,
indexPatterns,
}: DropHandlerProps<DataViewDragDropOperation>) {
const targetLayer = state.layers[target.layerId];
const targetColumn = targetLayer.columns[target.columnId] || null;
const sourceLayer = state.layers[source.layerId];
@ -272,22 +272,14 @@ function onMoveIncompatible(
return false;
}
const outputSourceLayer = shouldDeleteSource
? deleteColumn({
layer: sourceLayer,
columnId: source.columnId,
indexPattern,
})
: sourceLayer;
if (target.layerId === source.layerId) {
const newLayer = insertOrReplaceColumn({
layer: outputSourceLayer,
layer: sourceLayer,
columnId: target.columnId,
indexPattern,
op: newOperation,
field: sourceField,
visualizationGroups: dimensionGroups,
visualizationGroups: targetLayerDimensionGroups,
targetGroup: target.groupId,
shouldResetLabel: true,
});
@ -306,7 +298,7 @@ function onMoveIncompatible(
indexPattern,
op: newOperation,
field: sourceField,
visualizationGroups: dimensionGroups,
visualizationGroups: targetLayerDimensionGroups,
targetGroup: target.groupId,
shouldResetLabel: true,
});
@ -314,7 +306,7 @@ function onMoveIncompatible(
mergeLayers({
state,
newLayers: {
[source.layerId]: outputSourceLayer,
[source.layerId]: sourceLayer,
[target.layerId]: outputTargetLayer,
},
})
@ -327,7 +319,7 @@ function onSwapIncompatible({
setState,
state,
source,
dimensionGroups,
targetLayerDimensionGroups,
target,
indexPatterns,
}: DropHandlerProps<DragDropOperation>) {
@ -354,7 +346,7 @@ function onSwapIncompatible({
indexPattern,
op: newOperationForSource,
field: sourceField,
visualizationGroups: dimensionGroups,
visualizationGroups: targetLayerDimensionGroups,
shouldResetLabel: true,
});
@ -365,7 +357,7 @@ function onSwapIncompatible({
indexPattern,
op: newOperationForTarget,
field: targetField,
visualizationGroups: dimensionGroups,
visualizationGroups: targetLayerDimensionGroups,
targetGroup: source.groupId,
shouldResetLabel: true,
});
@ -384,7 +376,7 @@ function onSwapIncompatible({
indexPattern,
op: newOperationForTarget,
field: targetField,
visualizationGroups: dimensionGroups,
visualizationGroups: targetLayerDimensionGroups,
targetGroup: source.groupId,
shouldResetLabel: true,
});
@ -413,7 +405,7 @@ function onSwapCompatible({
setState,
state,
source,
dimensionGroups,
targetLayerDimensionGroups,
target,
}: DropHandlerProps<DataViewDragDropOperation>) {
if (target.layerId === source.layerId) {
@ -426,7 +418,7 @@ function onSwapCompatible({
let updatedColumnOrder = swapColumnOrder(layer.columnOrder, source.columnId, target.columnId);
updatedColumnOrder = reorderByGroups(
dimensionGroups,
targetLayerDimensionGroups,
updatedColumnOrder,
target.groupId,
target.columnId
@ -445,6 +437,7 @@ function onSwapCompatible({
return true;
} else {
// TODO why not reorderByGroups for both columns? Are they already in that order?
const newTargetLayer = copyColumn({
layers: state.layers,
target,
@ -478,7 +471,7 @@ function onCombine({
setState,
source,
target,
dimensionGroups,
targetLayerDimensionGroups,
indexPatterns,
}: DropHandlerProps<DataViewDragDropOperation>) {
const targetLayer = state.layers[target.layerId];
@ -509,16 +502,14 @@ function onCombine({
indexPattern,
op: targetColumn.operationType,
field: targetField,
visualizationGroups: dimensionGroups,
visualizationGroups: targetLayerDimensionGroups,
targetGroup: target.groupId,
initialParams,
shouldCombineField: true,
});
const newLayers = deleteColumnInLayers({
layers: { ...state.layers, [target.layerId]: outputTargetLayer },
source,
});
setState(mergeLayers({ state, newLayers }));
setState(
mergeLayers({ state, newLayers: { ...state.layers, [target.layerId]: outputTargetLayer } })
);
return true;
}

View file

@ -1632,7 +1632,7 @@ describe('IndexPattern Data Source', () => {
},
currentIndexPatternId: '1',
};
expect(FormBasedDatasource.insertLayer(state, 'newLayer')).toEqual({
expect(FormBasedDatasource.insertLayer(state, 'newLayer', ['link-to-id'])).toEqual({
...state,
layers: {
...state.layers,
@ -1640,6 +1640,7 @@ describe('IndexPattern Data Source', () => {
indexPatternId: '1',
columnOrder: [],
columns: {},
linkToLayers: ['link-to-id'],
},
},
});
@ -1867,6 +1868,7 @@ describe('IndexPattern Data Source', () => {
isBucketed: true,
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
} as OperationDescriptor);
});
@ -2722,6 +2724,43 @@ describe('IndexPattern Data Source', () => {
expect(publicAPI.getMaxPossibleNumValues('non-existant')).toEqual(null);
});
});
test('hasDefaultTimeField', () => {
const indexPatternWithDefaultTimeField = {
id: '1',
title: 'my-fake-index-pattern',
timeFieldName: 'timestamp',
hasRestrictions: false,
fields: fieldsOne,
getFieldByName: getFieldByNameFactory(fieldsOne),
spec: {},
isPersisted: true,
};
const indexPatternWithoutDefaultTimeField = {
...indexPatternWithDefaultTimeField,
timeFieldName: '',
};
expect(
FormBasedDatasource.getPublicAPI({
state: baseState,
layerId: 'first',
indexPatterns: {
1: indexPatternWithDefaultTimeField,
},
}).hasDefaultTimeField()
).toBe(true);
expect(
FormBasedDatasource.getPublicAPI({
state: baseState,
layerId: 'first',
indexPatterns: {
1: indexPatternWithoutDefaultTimeField,
},
}).hasDefaultTimeField()
).toBe(false);
});
});
describe('#getErrorMessages', () => {
@ -3218,6 +3257,7 @@ describe('IndexPattern Data Source', () => {
FormBasedDatasource.initializeDimension!(state, 'first', indexPatterns, {
columnId: 'newStatic',
groupId: 'a',
visualizationGroups: [],
})
).toBe(state);
});
@ -3246,6 +3286,7 @@ describe('IndexPattern Data Source', () => {
columnId: 'newStatic',
groupId: 'a',
staticValue: 0, // use a falsy value to check also this corner case
visualizationGroups: [],
})
).toEqual({
...state,
@ -3272,6 +3313,272 @@ describe('IndexPattern Data Source', () => {
},
});
});
it('should add a new date histogram column if autoTimeField is passed', () => {
const state = {
currentIndexPatternId: '1',
layers: {
first: {
indexPatternId: '1',
columnOrder: ['metric'],
columns: {
metric: {
label: 'Count of records',
dataType: 'number',
isBucketed: false,
sourceField: '___records___',
operationType: 'count',
},
},
},
},
} as FormBasedPrivateState;
expect(
FormBasedDatasource.initializeDimension!(state, 'first', indexPatterns, {
columnId: 'newTime',
groupId: 'a',
autoTimeField: true,
visualizationGroups: [],
})
).toEqual({
...state,
layers: {
...state.layers,
first: {
...state.layers.first,
incompleteColumns: {},
columnOrder: ['newTime', 'metric'],
columns: {
...state.layers.first.columns,
newTime: {
dataType: 'date',
isBucketed: true,
label: 'timestampLabel',
operationType: 'date_histogram',
params: { dropPartials: false, includeEmptyRows: true, interval: 'auto' },
scale: 'interval',
sourceField: 'timestamp',
},
},
},
},
});
});
});
describe('#syncColumns', () => {
it('copies linked columns', () => {
const links: Parameters<Datasource['syncColumns']>[0]['links'] = [
{
from: {
columnId: 'col1',
layerId: 'first',
groupId: 'foo',
},
to: {
columnId: 'col1',
layerId: 'second',
groupId: 'foo',
},
},
{
from: {
columnId: 'col2',
layerId: 'first',
groupId: 'foo',
},
to: {
columnId: 'new-col',
layerId: 'second',
groupId: 'foo',
},
},
];
const newState = FormBasedDatasource.syncColumns({
state: {
currentIndexPatternId: 'foo',
layers: {
first: {
indexPatternId: 'foo',
columnOrder: [],
columns: {
col1: {
operationType: 'sum',
label: '',
dataType: 'number',
isBucketed: false,
sourceField: 'field1',
customLabel: false,
timeScale: 'd',
} as SumIndexPatternColumn,
col2: {
sourceField: 'field2',
operationType: 'count',
customLabel: false,
timeScale: 'h',
} as CountIndexPatternColumn,
},
},
second: {
indexPatternId: 'foo',
columnOrder: [],
columns: {
col1: {
sourceField: 'field1',
operationType: 'count',
customLabel: false,
timeScale: 'd',
} as CountIndexPatternColumn,
},
},
},
},
links,
indexPatterns,
getDimensionGroups: () => [],
});
expect(newState).toMatchInlineSnapshot(`
Object {
"currentIndexPatternId": "foo",
"layers": Object {
"first": Object {
"columnOrder": Array [],
"columns": Object {
"col1": Object {
"customLabel": false,
"dataType": "number",
"isBucketed": false,
"label": "",
"operationType": "sum",
"sourceField": "field1",
"timeScale": "d",
},
"col2": Object {
"customLabel": false,
"operationType": "count",
"sourceField": "field2",
"timeScale": "h",
},
},
"indexPatternId": "foo",
},
"second": Object {
"columnOrder": Array [
"col1",
"new-col",
],
"columns": Object {
"col1": Object {
"customLabel": false,
"dataType": "number",
"isBucketed": false,
"label": "",
"operationType": "sum",
"sourceField": "field1",
"timeScale": "d",
},
"new-col": Object {
"customLabel": false,
"operationType": "count",
"sourceField": "field2",
"timeScale": "h",
},
},
"indexPatternId": "foo",
},
},
}
`);
});
it('updates terms order by references', () => {
const links: Parameters<Datasource['syncColumns']>[0]['links'] = [
{
from: {
columnId: 'col1FirstLayer',
layerId: 'first',
groupId: 'foo',
},
to: {
columnId: 'col1SecondLayer',
layerId: 'second',
groupId: 'foo',
},
},
{
from: {
columnId: 'col2',
layerId: 'first',
groupId: 'foo',
},
to: {
columnId: 'new-col',
layerId: 'second',
groupId: 'foo',
},
},
];
const newState = FormBasedDatasource.syncColumns({
state: {
currentIndexPatternId: 'foo',
layers: {
first: {
indexPatternId: 'foo',
columnOrder: [],
columns: {
col1FirstLayer: {
operationType: 'sum',
label: '',
dataType: 'number',
isBucketed: false,
sourceField: 'field1',
customLabel: false,
timeScale: 'd',
} as SumIndexPatternColumn,
col2: {
operationType: 'terms',
sourceField: 'field2',
label: '',
dataType: 'number',
isBucketed: false,
params: {
orderBy: {
columnId: 'col1FirstLayer',
type: 'column',
},
},
} as TermsIndexPatternColumn,
},
},
second: {
indexPatternId: 'foo',
columnOrder: [],
columns: {
col1SecondLayer: {
sourceField: 'field1',
operationType: 'count',
customLabel: false,
timeScale: 'd',
} as CountIndexPatternColumn,
},
},
},
},
links,
indexPatterns,
getDimensionGroups: () => [],
});
expect(
(newState.layers.second.columns['new-col'] as TermsIndexPatternColumn).params.orderBy
).toEqual({
type: 'column',
columnId: 'col1SecondLayer',
});
});
});
describe('#isEqual', () => {

View file

@ -80,9 +80,14 @@ import {
operationDefinitionMap,
TermsIndexPatternColumn,
} from './operations';
import { getReferenceRoot } from './operations/layer_helpers';
import { FormBasedPrivateState, FormBasedPersistedState } from './types';
import { mergeLayer } from './state_helpers';
import {
copyColumn,
getColumnOrder,
getReferenceRoot,
reorderByGroups,
} from './operations/layer_helpers';
import { FormBasedPrivateState, FormBasedPersistedState, DataViewDragDropOperation } from './types';
import { mergeLayer, mergeLayers } from './state_helpers';
import { Datasource, VisualizeEditorContext } from '../../types';
import { deleteColumn, isReferenced } from './operations';
import { GeoFieldWorkspacePanel } from '../../editor_frame_service/editor_frame/workspace_panel/geo_field_workspace_panel';
@ -91,6 +96,7 @@ import { getStateTimeShiftWarningMessages } from './time_shift_utils';
import { getPrecisionErrorWarningMessages } from './utils';
import { DOCUMENT_FIELD_NAME } from '../../../common/constants';
import { isColumnOfType } from './operations/definitions/helpers';
import { FormBasedLayer } from '../..';
export type { OperationType, GenericIndexPatternColumn } from './operations';
export { deleteColumn } from './operations';
@ -99,7 +105,7 @@ export function columnToOperation(
uniqueLabel?: string,
dataView?: IndexPattern
): OperationDescriptor {
const { dataType, label, isBucketed, scale, operationType, timeShift } = column;
const { dataType, label, isBucketed, scale, operationType, timeShift, reducedTimeRange } = column;
const fieldTypes =
'sourceField' in column ? dataView?.getFieldByName(column.sourceField)?.esTypes : undefined;
return {
@ -113,6 +119,7 @@ export function columnToOperation(
? 'version'
: undefined,
hasTimeShift: Boolean(timeShift),
hasReducedTimeRange: Boolean(reducedTimeRange),
interval: isColumnOfType<DateHistogramIndexPatternColumn>('date_histogram', column)
? column.params.interval
: undefined,
@ -180,12 +187,16 @@ export function getFormBasedDatasource({
return extractReferences(state);
},
insertLayer(state: FormBasedPrivateState, newLayerId: string) {
insertLayer(
state: FormBasedPrivateState,
newLayerId: string,
linkToLayers: string[] | undefined
) {
return {
...state,
layers: {
...state.layers,
[newLayerId]: blankLayer(state.currentIndexPatternId),
[newLayerId]: blankLayer(state.currentIndexPatternId, linkToLayers),
},
};
},
@ -219,7 +230,7 @@ export function getFormBasedDatasource({
...state,
layers: {
...state.layers,
[layerId]: blankLayer(state.currentIndexPatternId),
[layerId]: blankLayer(state.currentIndexPatternId, state.layers[layerId].linkToLayers),
},
};
},
@ -241,26 +252,123 @@ export function getFormBasedDatasource({
});
},
initializeDimension(state, layerId, indexPatterns, { columnId, groupId, staticValue }) {
initializeDimension(
state,
layerId,
indexPatterns,
{ columnId, groupId, staticValue, autoTimeField, visualizationGroups }
) {
const indexPattern = indexPatterns[state.layers[layerId]?.indexPatternId];
if (staticValue == null) {
return state;
let ret = state;
if (staticValue != null) {
ret = mergeLayer({
state,
layerId,
newLayer: insertNewColumn({
layer: state.layers[layerId],
op: 'static_value',
columnId,
field: undefined,
indexPattern,
visualizationGroups,
initialParams: { params: { value: staticValue } },
targetGroup: groupId,
}),
});
}
return mergeLayer({
state,
layerId,
newLayer: insertNewColumn({
layer: state.layers[layerId],
op: 'static_value',
columnId,
field: undefined,
indexPattern,
visualizationGroups: [],
initialParams: { params: { value: staticValue } },
targetGroup: groupId,
}),
if (autoTimeField && indexPattern.timeFieldName) {
ret = mergeLayer({
state,
layerId,
newLayer: insertNewColumn({
layer: state.layers[layerId],
op: 'date_histogram',
columnId,
field: indexPattern.fields.find((field) => field.name === indexPattern.timeFieldName),
indexPattern,
visualizationGroups,
targetGroup: groupId,
}),
});
}
return ret;
},
syncColumns({ state, links, indexPatterns, getDimensionGroups }) {
let modifiedLayers: Record<string, FormBasedLayer> = state.layers;
links.forEach((link) => {
const source: DataViewDragDropOperation = {
...link.from,
dataView: indexPatterns[modifiedLayers[link.from.layerId]?.indexPatternId],
filterOperations: () => true,
};
const target: DataViewDragDropOperation = {
...link.to,
dataView: indexPatterns[modifiedLayers[link.to.layerId]?.indexPatternId],
filterOperations: () => true,
};
modifiedLayers = copyColumn({
layers: modifiedLayers,
target,
source,
});
const updatedColumnOrder = reorderByGroups(
getDimensionGroups(target.layerId),
getColumnOrder(modifiedLayers[target.layerId]),
target.groupId,
target.columnId
);
modifiedLayers = {
...modifiedLayers,
[target.layerId]: {
...modifiedLayers[target.layerId],
columnOrder: updatedColumnOrder,
columns: modifiedLayers[target.layerId].columns,
},
};
});
const newState = mergeLayers({
state,
newLayers: modifiedLayers,
});
links
.filter((link) =>
isColumnOfType<TermsIndexPatternColumn>(
'terms',
newState.layers[link.from.layerId].columns[link.from.columnId]
)
)
.forEach(({ from, to }) => {
const fromColumn = newState.layers[from.layerId].columns[
from.columnId
] as TermsIndexPatternColumn;
if (fromColumn.params.orderBy.type === 'column') {
const fromOrderByColumnId = fromColumn.params.orderBy.columnId;
const orderByColumnLink = links.find(
({ from: { columnId } }) => columnId === fromOrderByColumnId
);
if (orderByColumnLink) {
// order the synced column by the dimension which is linked to the column that the original column was ordered by
const toColumn = newState.layers[to.layerId].columns[
to.columnId
] as TermsIndexPatternColumn;
toColumn.params.orderBy = { type: 'column', columnId: orderByColumnLink.to.columnId };
}
}
});
return newState;
},
getSelectedFields(state) {
@ -497,9 +605,18 @@ export function getFormBasedDatasource({
onRefreshIndexPattern,
onIndexPatternChange(state, indexPatterns, indexPatternId, layerId) {
if (layerId) {
const layersToChange = [
layerId,
...Object.entries(state.layers)
.map(([possiblyLinkedId, layer]) =>
layer.linkToLayers?.includes(layerId) ? possiblyLinkedId : ''
)
.filter(Boolean),
];
return changeLayerIndexPattern({
indexPatternId,
layerId,
layerIds: layersToChange,
state,
replaceIfPossible: true,
storage,
@ -620,6 +737,7 @@ export function getFormBasedDatasource({
}
return null;
},
hasDefaultTimeField: () => Boolean(indexPatterns[layer.indexPatternId].timeFieldName),
};
},
getDatasourceSuggestionsForField(state, draggedField, filterLayers, indexPatterns) {
@ -799,9 +917,10 @@ export function getFormBasedDatasource({
return formBasedDatasource;
}
function blankLayer(indexPatternId: string) {
function blankLayer(indexPatternId: string, linkToLayers?: string[]): FormBasedLayer {
return {
indexPatternId,
linkToLayers,
columns: {},
columnOrder: [],
};

View file

@ -1283,6 +1283,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: undefined,
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
},
},
{
@ -1294,6 +1295,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: 'ratio',
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
},
},
],
@ -1373,6 +1375,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: undefined,
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
},
},
{
@ -1384,6 +1387,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: 'ratio',
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
},
},
],
@ -2198,6 +2202,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: undefined,
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
},
},
],
@ -2222,6 +2227,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: undefined,
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
},
},
],
@ -2272,6 +2278,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: 'interval',
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
interval: 'auto',
},
},
@ -2284,6 +2291,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: 'ratio',
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
interval: undefined,
},
},
@ -2349,6 +2357,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: 'ordinal',
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
interval: undefined,
},
},
@ -2361,6 +2370,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: 'interval',
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
interval: 'auto',
},
},
@ -2373,6 +2383,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: 'ratio',
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
interval: undefined,
},
},
@ -2459,6 +2470,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: 'ordinal',
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
interval: undefined,
},
},
@ -2471,6 +2483,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: 'interval',
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
interval: 'auto',
},
},
@ -2483,6 +2496,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: 'ratio',
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
interval: undefined,
},
},
@ -2592,6 +2606,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: 'ordinal',
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
interval: undefined,
},
},
@ -2604,6 +2619,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: 'interval',
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
interval: 'auto',
},
},
@ -2616,6 +2632,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: undefined,
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
interval: undefined,
},
},
@ -3123,6 +3140,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: undefined,
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
},
},
{
@ -3134,6 +3152,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: undefined,
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
},
},
],
@ -3201,6 +3220,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: 'interval',
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
interval: 'auto',
},
},
@ -3213,6 +3233,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: undefined,
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
interval: undefined,
},
},
@ -3225,6 +3246,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: undefined,
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
interval: undefined,
},
},
@ -3291,6 +3313,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: undefined,
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
interval: 'auto',
},
},
@ -3303,6 +3326,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: undefined,
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
interval: undefined,
},
},
@ -3315,6 +3339,7 @@ describe('IndexPattern Data Source suggestions', () => {
scale: undefined,
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
interval: undefined,
},
},

View file

@ -401,7 +401,7 @@ describe('loader', () => {
});
describe('changeLayerIndexPattern', () => {
it('loads the index pattern and then changes the specified layer', async () => {
it('loads the index pattern and then changes the specified layers', async () => {
const state: FormBasedPrivateState = {
currentIndexPatternId: '1',
layers: {
@ -434,7 +434,7 @@ describe('loader', () => {
const newState = changeLayerIndexPattern({
state,
indexPatternId: '2',
layerId: 'l1',
layerIds: ['l0', 'l1'],
storage,
indexPatterns: sampleIndexPatterns,
replaceIfPossible: true,
@ -444,9 +444,9 @@ describe('loader', () => {
currentIndexPatternId: '2',
layers: {
l0: {
columnOrder: ['col1'],
columnOrder: [],
columns: {},
indexPatternId: '1',
indexPatternId: '2',
},
l1: {
columnOrder: ['col2'],

View file

@ -253,25 +253,34 @@ export function triggerActionOnIndexPatternChange({
export function changeLayerIndexPattern({
indexPatternId,
indexPatterns,
layerId,
layerIds,
state,
replaceIfPossible,
storage,
}: {
indexPatternId: string;
layerId: string;
layerIds: string[];
state: FormBasedPrivateState;
replaceIfPossible?: boolean;
storage: IStorageWrapper;
indexPatterns: Record<string, IndexPattern>;
}) {
setLastUsedIndexPatternId(storage, indexPatternId);
const newLayers = {
...state.layers,
};
layerIds.forEach((layerId) => {
newLayers[layerId] = updateLayerIndexPattern(
state.layers[layerId],
indexPatterns[indexPatternId]
);
});
return {
...state,
layers: {
...state.layers,
[layerId]: updateLayerIndexPattern(state.layers[layerId], indexPatterns[indexPatternId]),
},
layers: newLayers,
currentIndexPatternId: replaceIfPossible ? indexPatternId : state.currentIndexPatternId,
};
}

View file

@ -74,34 +74,8 @@ interface ColumnCopy {
shouldDeleteSource?: boolean;
}
export const deleteColumnInLayers = ({
layers,
source,
}: {
layers: Record<string, FormBasedLayer>;
source: DataViewDragDropOperation;
}) => ({
...layers,
[source.layerId]: deleteColumn({
layer: layers[source.layerId],
columnId: source.columnId,
indexPattern: source.dataView,
}),
});
export function copyColumn({
layers,
source,
target,
shouldDeleteSource,
}: ColumnCopy): Record<string, FormBasedLayer> {
const outputLayers = createCopiedColumn(layers, target, source);
return shouldDeleteSource
? deleteColumnInLayers({
layers: outputLayers,
source,
})
: outputLayers;
export function copyColumn({ layers, source, target }: ColumnCopy): Record<string, FormBasedLayer> {
return createCopiedColumn(layers, target, source);
}
function createCopiedColumn(

View file

@ -51,6 +51,7 @@ export interface FormBasedLayer {
columns: Record<string, GenericIndexPatternColumn>;
// Each layer is tied to the index pattern that created it
indexPatternId: string;
linkToLayers?: string[];
// Partial columns represent the temporary invalid states
incompleteColumns?: Record<string, IncompleteColumn>;
}

View file

@ -728,6 +728,7 @@ describe('IndexPattern Data Source', () => {
dataType: 'number',
isBucketed: false,
hasTimeShift: false,
hasReducedTimeRange: false,
});
});

View file

@ -209,6 +209,12 @@ export function getTextBasedDatasource({
initialContext: context,
};
},
syncColumns({ state }) {
// TODO implement this for real
return state;
},
onRefreshIndexPattern() {},
getUsedDataViews: (state) => {
@ -581,6 +587,7 @@ export function getTextBasedDatasource({
label: columnLabelMap[columnId] ?? column?.fieldName,
isBucketed: Boolean(column?.meta?.type !== 'number'),
hasTimeShift: false,
hasReducedTimeRange: false,
};
}
return null;
@ -606,6 +613,7 @@ export function getTextBasedDatasource({
},
};
},
hasDefaultTimeField: () => false,
};
},
getDatasourceSuggestionsForField(state, draggedField) {

View file

@ -44,10 +44,12 @@ export function AddLayerButton({
if (!visualization.appendLayer || !visualizationState) {
return null;
}
return visualization.getSupportedLayers?.(visualizationState, layersMeta);
return visualization
.getSupportedLayers?.(visualizationState, layersMeta)
?.filter(({ canAddViaMenu: hideFromMenu }) => !hideFromMenu);
}, [visualization, visualizationState, layersMeta]);
if (supportedLayers == null) {
if (supportedLayers == null || !supportedLayers.length) {
return null;
}
if (supportedLayers.length === 1) {

View file

@ -147,11 +147,23 @@ export interface OnVisDropProps<T> {
group?: VisualizationDimensionGroupConfig;
}
export function shouldRemoveSource(source: DragDropIdentifier, dropType: DropType) {
return (
isOperation(source) &&
(dropType === 'move_compatible' ||
dropType === 'move_incompatible' ||
dropType === 'combine_incompatible' ||
dropType === 'combine_compatible' ||
dropType === 'replace_compatible' ||
dropType === 'replace_incompatible')
);
}
export function onDropForVisualization<T, P = unknown>(
props: OnVisDropProps<T>,
activeVisualization: Visualization<T, P>
) {
const { prevState, target, frame, dropType, source, group } = props;
const { prevState, target, frame, source, group } = props;
const { layerId, columnId, groupId } = target;
const previousColumn =
@ -166,21 +178,5 @@ export function onDropForVisualization<T, P = unknown>(
frame,
});
if (
isOperation(source) &&
(dropType === 'move_compatible' ||
dropType === 'move_incompatible' ||
dropType === 'combine_incompatible' ||
dropType === 'combine_compatible' ||
dropType === 'replace_compatible' ||
dropType === 'replace_incompatible')
) {
return activeVisualization.removeDimension({
columnId: source?.columnId,
layerId: source?.layerId,
prevState: newVisState,
frame,
});
}
return newVisState;
}

View file

@ -374,6 +374,16 @@ describe('ConfigPanel', () => {
columnId: 'myColumn',
groupId: 'testGroup',
staticValue: 100,
visualizationGroups: [
expect.objectContaining({
accessors: [],
dataTestSubj: 'mockVisA',
groupId: 'a',
groupLabel: 'a',
layerId: 'layer1',
supportsMoreColumns: true,
}),
],
}
);
});
@ -410,6 +420,16 @@ describe('ConfigPanel', () => {
groupId: 'a',
columnId: 'newId',
staticValue: 100,
visualizationGroups: [
expect.objectContaining({
accessors: [],
dataTestSubj: 'mockVisA',
groupId: 'a',
groupLabel: 'a',
layerId: 'layer1',
supportsMoreColumns: true,
}),
],
}
);
});

View file

@ -13,7 +13,8 @@ import {
UPDATE_FILTER_REFERENCES_ACTION,
UPDATE_FILTER_REFERENCES_TRIGGER,
} from '@kbn/unified-search-plugin/public';
import { changeIndexPattern } from '../../../state_management/lens_slice';
import { LayerType } from '../../../../common';
import { changeIndexPattern, removeDimension } from '../../../state_management/lens_slice';
import { Visualization } from '../../../types';
import { LayerPanel } from './layer_panel';
import { generateId } from '../../../id_generator';
@ -24,7 +25,7 @@ import {
useLensDispatch,
removeOrClearLayer,
cloneLayer,
addLayer,
addLayer as addLayerAction,
updateState,
updateDatasourceState,
updateVisualizationState,
@ -78,18 +79,20 @@ export function LayerPanels(
[activeVisualization, dispatchLens]
);
const updateDatasource = useMemo(
() => (datasourceId: string | undefined, newState: unknown) => {
if (datasourceId) {
dispatchLens(
updateDatasourceState({
updater: (prevState: unknown) =>
typeof newState === 'function' ? newState(prevState) : newState,
datasourceId,
clearStagedPreview: false,
})
);
}
},
() =>
(datasourceId: string | undefined, newState: unknown, dontSyncLinkedDimensions?: boolean) => {
if (datasourceId) {
dispatchLens(
updateDatasourceState({
updater: (prevState: unknown) =>
typeof newState === 'function' ? newState(prevState) : newState,
datasourceId,
clearStagedPreview: false,
dontSyncLinkedDimensions,
})
);
}
},
[dispatchLens]
);
const updateDatasourceAsync = useMemo(
@ -157,6 +160,48 @@ export function LayerPanels(
[dispatchLens]
);
const onRemoveLayer = useCallback(
(layerToRemoveId: string) => {
const datasourcePublicAPI = props.framePublicAPI.datasourceLayers?.[layerToRemoveId];
const datasourceId = datasourcePublicAPI?.datasourceId;
if (datasourceId) {
const layerDatasource = datasourceMap[datasourceId];
const layerDatasourceState = datasourceStates?.[datasourceId]?.state;
const trigger = props.uiActions.getTrigger(UPDATE_FILTER_REFERENCES_TRIGGER);
const action = props.uiActions.getAction(UPDATE_FILTER_REFERENCES_ACTION);
action?.execute({
trigger,
fromDataView: layerDatasource.getUsedDataView(layerDatasourceState, layerToRemoveId),
usedDataViews: layerDatasource
.getLayers(layerDatasourceState)
.map((layer) => layerDatasource.getUsedDataView(layerDatasourceState, layer)),
defaultDataView: layerDatasource.getUsedDataView(layerDatasourceState),
} as ActionExecutionContext);
}
dispatchLens(
removeOrClearLayer({
visualizationId: activeVisualization.id,
layerId: layerToRemoveId,
layerIds,
})
);
removeLayerRef(layerToRemoveId);
},
[
activeVisualization.id,
datasourceMap,
datasourceStates,
dispatchLens,
layerIds,
props.framePublicAPI.datasourceLayers,
props.uiActions,
removeLayerRef,
]
);
const onChangeIndexPattern = useCallback(
async ({
indexPatternId,
@ -183,100 +228,98 @@ export function LayerPanels(
})
);
},
[dispatchLens, props.framePublicAPI.dataViews, props.indexPatternService]
[dispatchLens, props.framePublicAPI.dataViews.indexPatterns, props.indexPatternService]
);
const addLayer = (layerType: LayerType) => {
const layerId = generateId();
dispatchLens(addLayerAction({ layerId, layerType }));
setNextFocusedLayerId(layerId);
};
const hideAddLayerButton = query && isOfAggregateQueryType(query);
return (
<EuiForm className="lnsConfigPanel">
{layerIds.map((layerId, layerIndex) => (
<LayerPanel
{...props}
activeVisualization={activeVisualization}
registerNewLayerRef={registerNewLayerRef}
key={layerId}
layerId={layerId}
layerIndex={layerIndex}
visualizationState={visualization.state}
updateVisualization={setVisualizationState}
updateDatasource={updateDatasource}
updateDatasourceAsync={updateDatasourceAsync}
onChangeIndexPattern={onChangeIndexPattern}
updateAll={updateAll}
isOnlyLayer={
getRemoveOperation(
activeVisualization,
visualization.state,
layerId,
layerIds.length
) === 'clear'
}
onEmptyDimensionAdd={(columnId, { groupId }) => {
// avoid state update if the datasource does not support initializeDimension
if (
activeDatasourceId != null &&
datasourceMap[activeDatasourceId]?.initializeDimension
) {
dispatchLens(
setLayerDefaultDimension({
{layerIds.map((layerId, layerIndex) => {
const { hidden, groups } = activeVisualization.getConfiguration({
layerId,
frame: props.framePublicAPI,
state: visualization.state,
});
return (
!hidden && (
<LayerPanel
{...props}
dimensionGroups={groups}
activeVisualization={activeVisualization}
registerNewLayerRef={registerNewLayerRef}
key={layerId}
layerId={layerId}
layerIndex={layerIndex}
visualizationState={visualization.state}
updateVisualization={setVisualizationState}
updateDatasource={updateDatasource}
updateDatasourceAsync={updateDatasourceAsync}
onChangeIndexPattern={(args) => {
onChangeIndexPattern(args);
const layersToRemove =
activeVisualization.getLayersToRemoveOnIndexPatternChange?.(
visualization.state
) ?? [];
layersToRemove.forEach((id) => onRemoveLayer(id));
}}
updateAll={updateAll}
addLayer={addLayer}
isOnlyLayer={
getRemoveOperation(
activeVisualization,
visualization.state,
layerId,
columnId,
groupId,
})
);
}
}}
onCloneLayer={() => {
dispatchLens(
cloneLayer({
layerId,
})
);
}}
onRemoveLayer={() => {
const datasourcePublicAPI = props.framePublicAPI.datasourceLayers?.[layerId];
const datasourceId = datasourcePublicAPI?.datasourceId;
if (datasourceId) {
const layerDatasource = datasourceMap[datasourceId];
const layerDatasourceState = datasourceStates?.[datasourceId]?.state;
const trigger = props.uiActions.getTrigger(UPDATE_FILTER_REFERENCES_TRIGGER);
const action = props.uiActions.getAction(UPDATE_FILTER_REFERENCES_ACTION);
action?.execute({
trigger,
fromDataView: layerDatasource.getUsedDataView(layerDatasourceState, layerId),
usedDataViews: layerDatasource
.getLayers(layerDatasourceState)
.map((layer) => layerDatasource.getUsedDataView(layerDatasourceState, layer)),
defaultDataView: layerDatasource.getUsedDataView(layerDatasourceState),
} as ActionExecutionContext);
}
dispatchLens(
removeOrClearLayer({
visualizationId: activeVisualization.id,
layerId,
layerIds,
})
);
removeLayerRef(layerId);
}}
toggleFullscreen={toggleFullscreen}
indexPatternService={indexPatternService}
/>
))}
layerIds.length
) === 'clear'
}
onEmptyDimensionAdd={(columnId, { groupId }) => {
// avoid state update if the datasource does not support initializeDimension
if (
activeDatasourceId != null &&
datasourceMap[activeDatasourceId]?.initializeDimension
) {
dispatchLens(
setLayerDefaultDimension({
layerId,
columnId,
groupId,
})
);
}
}}
onCloneLayer={() => {
dispatchLens(
cloneLayer({
layerId,
})
);
}}
onRemoveLayer={onRemoveLayer}
onRemoveDimension={(dimensionProps) => {
const datasourcePublicAPI = props.framePublicAPI.datasourceLayers?.[layerId];
const datasourceId = datasourcePublicAPI?.datasourceId;
dispatchLens(removeDimension({ ...dimensionProps, datasourceId }));
}}
toggleFullscreen={toggleFullscreen}
indexPatternService={indexPatternService}
/>
)
);
})}
{!hideAddLayerButton && (
<AddLayerButton
visualization={activeVisualization}
visualizationState={visualization.state}
layersMeta={props.framePublicAPI}
onAddLayerClick={(layerType) => {
const layerId = generateId();
dispatchLens(addLayer({ layerId, layerType }));
setNextFocusedLayerId(layerId);
}}
onAddLayerClick={(layerType) => addLayer(layerType)}
/>
)}
</EuiForm>

View file

@ -8,7 +8,7 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { EuiFormRow } from '@elastic/eui';
import { FramePublicAPI, Visualization } from '../../../types';
import { FramePublicAPI, Visualization, VisualizationConfigProps } from '../../../types';
import { LayerPanel } from './layer_panel';
import { ChildDragDropProvider, DragDrop } from '../../../drag_drop';
import { coreMock } from '@kbn/core/public/mocks';
@ -89,6 +89,7 @@ describe('LayerPanel', () => {
return {
layerId: 'first',
activeVisualization: mockVisualization,
dimensionGroups: mockVisualization.getConfiguration({} as VisualizationConfigProps).groups,
datasourceMap: {
testDatasource: mockDatasource,
},
@ -99,8 +100,10 @@ describe('LayerPanel', () => {
updateAll: jest.fn(),
framePublicAPI: frame,
isOnlyLayer: true,
addLayer: jest.fn(),
onRemoveLayer: jest.fn(),
onCloneLayer: jest.fn(),
onRemoveDimension: jest.fn(),
dispatch: jest.fn(),
core: coreMock.createStart(),
layerIndex: 0,
@ -473,9 +476,6 @@ describe('LayerPanel', () => {
});
it('should remove the dimension when the datasource marks it as removed', async () => {
const updateAll = jest.fn();
const updateDatasource = jest.fn();
mockVisualization.getConfiguration.mockReturnValue({
groups: [
{
@ -489,37 +489,31 @@ describe('LayerPanel', () => {
],
});
const { instance } = await mountWithProvider(
<LayerPanel
{...getDefaultProps()}
updateDatasource={updateDatasource}
updateAll={updateAll}
/>,
{
preloadedState: {
datasourceStates: {
testDatasource: {
isLoading: false,
state: {
layers: [
{
indexPatternId: '1',
columns: {
y: {
operationType: 'moving_average',
references: ['ref'],
},
const props = getDefaultProps();
const { instance } = await mountWithProvider(<LayerPanel {...props} />, {
preloadedState: {
datasourceStates: {
testDatasource: {
isLoading: false,
state: {
layers: [
{
indexPatternId: '1',
columns: {
y: {
operationType: 'moving_average',
references: ['ref'],
},
columnOrder: ['y'],
incompleteColumns: {},
},
],
},
columnOrder: ['y'],
incompleteColumns: {},
},
],
},
},
},
}
);
},
});
act(() => {
instance.find('[data-test-subj="lnsLayerPanel-dimensionLink"]').last().simulate('click');
@ -548,12 +542,10 @@ describe('LayerPanel', () => {
}
);
});
expect(updateAll).toHaveBeenCalled();
expect(mockVisualization.removeDimension).toHaveBeenCalledWith(
expect.objectContaining({
columnId: 'y',
})
);
expect(props.onRemoveDimension).toHaveBeenCalledWith({
layerId: props.layerId,
columnId: 'y',
});
});
it('should keep the DimensionContainer open when configuring a new dimension', async () => {
@ -985,7 +977,6 @@ describe('LayerPanel', () => {
it('should call onDrop and update visualization when replacing between compatible groups', async () => {
const mockVis = {
...mockVisualization,
removeDimension: jest.fn(),
setDimension: jest.fn(() => 'modifiedState'),
};
mockVis.getConfiguration.mockReturnValue({
@ -1019,11 +1010,13 @@ describe('LayerPanel', () => {
mockDatasource.onDrop.mockReturnValue(true);
const updateVisualization = jest.fn();
const mockOnRemoveDimension = jest.fn();
const { instance } = await mountWithProvider(
<ChildDragDropProvider {...defaultContext} dragging={draggingOperation}>
<LayerPanel
{...getDefaultProps()}
onRemoveDimension={mockOnRemoveDimension}
updateVisualization={updateVisualization}
activeVisualization={mockVis}
/>
@ -1047,19 +1040,15 @@ describe('LayerPanel', () => {
prevState: 'state',
})
);
expect(mockVis.removeDimension).toHaveBeenCalledWith(
expect.objectContaining({
columnId: 'a',
layerId: 'first',
prevState: 'modifiedState',
})
);
expect(mockOnRemoveDimension).toHaveBeenCalledWith({
columnId: 'a',
layerId: 'first',
});
expect(updateVisualization).toHaveBeenCalledTimes(1);
});
it('should call onDrop and update visualization when replacing between compatible groups2', async () => {
const mockVis = {
...mockVisualization,
removeDimension: jest.fn(),
setDimension: jest.fn(() => 'modifiedState'),
onDrop: jest.fn(() => 'modifiedState'),
};
@ -1096,11 +1085,13 @@ describe('LayerPanel', () => {
mockDatasource.onDrop.mockReturnValue(true);
const updateVisualization = jest.fn();
const mockOnRemoveDimension = jest.fn();
const { instance } = await mountWithProvider(
<ChildDragDropProvider {...defaultContext} dragging={draggingOperation}>
<LayerPanel
{...getDefaultProps()}
onRemoveDimension={mockOnRemoveDimension}
updateVisualization={updateVisualization}
activeVisualization={mockVis}
/>
@ -1132,9 +1123,130 @@ describe('LayerPanel', () => {
mockVis
);
expect(mockVis.setDimension).not.toHaveBeenCalled();
expect(mockVis.removeDimension).not.toHaveBeenCalled();
expect(mockOnRemoveDimension).toHaveBeenCalledWith({
columnId: 'a',
layerId: 'first',
});
expect(updateVisualization).toHaveBeenCalledTimes(1);
});
it('should not change visualization state if datasource drop failed', async () => {
const mockVis = {
...mockVisualization,
setDimension: jest.fn(() => 'modifiedState'),
};
mockVis.getConfiguration.mockReturnValue({
groups: [
{
groupLabel: 'A',
groupId: 'a',
accessors: [{ columnId: 'a' }, { columnId: 'b' }],
filterOperations: () => true,
supportsMoreColumns: true,
dataTestSubj: 'lnsGroup',
},
{
groupLabel: 'B',
groupId: 'b',
accessors: [{ columnId: 'c' }],
filterOperations: () => true,
supportsMoreColumns: true,
dataTestSubj: 'lnsGroup2',
},
],
});
const draggingOperation = {
layerId: 'first',
columnId: 'a',
groupId: 'a',
id: 'a',
humanData: { label: 'Label' },
};
mockDatasource.onDrop.mockReturnValue(false);
const updateVisualization = jest.fn();
const mockOnRemoveDimension = jest.fn();
const { instance } = await mountWithProvider(
<ChildDragDropProvider {...defaultContext} dragging={draggingOperation}>
<LayerPanel
{...getDefaultProps()}
onRemoveDimension={mockOnRemoveDimension}
updateVisualization={updateVisualization}
activeVisualization={mockVis}
/>
</ChildDragDropProvider>
);
act(() => {
instance.find(DragDrop).at(3).prop('onDrop')!(draggingOperation, 'replace_compatible');
});
expect(mockDatasource.onDrop).toHaveBeenCalledWith(
expect.objectContaining({
dropType: 'replace_compatible',
source: draggingOperation,
})
);
expect(updateVisualization).not.toHaveBeenCalled();
expect(mockOnRemoveDimension).not.toHaveBeenCalled();
});
it("should not remove source if drop type doesn't require it", async () => {
const mockVis = {
...mockVisualization,
setDimension: jest.fn(() => 'modifiedState'),
};
mockVis.getConfiguration.mockReturnValue({
groups: [
{
groupLabel: 'A',
groupId: 'a',
accessors: [{ columnId: 'a' }, { columnId: 'b' }],
filterOperations: () => true,
supportsMoreColumns: true,
dataTestSubj: 'lnsGroup',
},
{
groupLabel: 'B',
groupId: 'b',
accessors: [{ columnId: 'c' }],
filterOperations: () => true,
supportsMoreColumns: true,
dataTestSubj: 'lnsGroup2',
},
],
});
const draggingOperation = {
layerId: 'first',
columnId: 'a',
groupId: 'a',
id: 'a',
humanData: { label: 'Label' },
};
mockDatasource.onDrop.mockReturnValue(true);
const mockOnRemoveDimension = jest.fn();
const { instance } = await mountWithProvider(
<ChildDragDropProvider {...defaultContext} dragging={draggingOperation}>
<LayerPanel
{...getDefaultProps()}
onRemoveDimension={mockOnRemoveDimension}
activeVisualization={mockVis}
/>
</ChildDragDropProvider>
);
act(() => {
instance.find(DragDrop).at(3).prop('onDrop')!(draggingOperation, 'duplicate_compatible');
});
expect(mockOnRemoveDimension).not.toHaveBeenCalled();
});
});
describe('add a new dimension', () => {

View file

@ -18,6 +18,7 @@ import {
EuiIconTip,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { LayerType } from '../../../../common';
import { LayerActions } from './layer_actions';
import { IndexPatternServiceAPI } from '../../../data_views_service/service';
import { NativeRenderer } from '../../../native_renderer';
@ -27,6 +28,7 @@ import {
DragDropOperation,
DropType,
isOperation,
VisualizationDimensionGroupConfig,
} from '../../../types';
import { DragDropIdentifier, ReorderProvider } from '../../../drag_drop';
import { LayerSettings } from './layer_settings';
@ -42,7 +44,7 @@ import {
selectResolvedDateRange,
selectDatasourceStates,
} from '../../../state_management';
import { onDropForVisualization } from './buttons/drop_targets_utils';
import { onDropForVisualization, shouldRemoveSource } from './buttons/drop_targets_utils';
import { getSharedActions } from './layer_actions/layer_actions';
const initialActiveDimensionState = {
@ -52,19 +54,26 @@ const initialActiveDimensionState = {
export function LayerPanel(
props: Exclude<LayerPanelProps, 'state' | 'setState'> & {
activeVisualization: Visualization;
dimensionGroups: VisualizationDimensionGroupConfig[];
layerId: string;
layerIndex: number;
isOnlyLayer: boolean;
addLayer: (layerType: LayerType) => void;
updateVisualization: StateSetter<unknown>;
updateDatasource: (datasourceId: string | undefined, newState: unknown) => void;
updateDatasource: (
datasourceId: string | undefined,
newState: unknown,
dontSyncLinkedDimensions?: boolean
) => void;
updateDatasourceAsync: (datasourceId: string | undefined, newState: unknown) => void;
updateAll: (
datasourceId: string | undefined,
newDatasourcestate: unknown,
newVisualizationState: unknown
) => void;
onRemoveLayer: () => void;
onRemoveLayer: (layerId: string) => void;
onCloneLayer: () => void;
onRemoveDimension: (props: { columnId: string; layerId: string }) => void;
registerNewLayerRef: (layerId: string, instance: HTMLDivElement | null) => void;
toggleFullscreen: () => void;
onEmptyDimensionAdd: (columnId: string, group: { groupId: string }) => void;
@ -86,6 +95,7 @@ export function LayerPanel(
framePublicAPI,
layerId,
isOnlyLayer,
dimensionGroups,
onRemoveLayer,
onCloneLayer,
registerNewLayerRef,
@ -138,26 +148,15 @@ export function LayerPanel(
dateRange,
};
const { groups } = useMemo(
() => activeVisualization.getConfiguration(layerVisualizationConfigProps),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
layerVisualizationConfigProps.frame,
layerVisualizationConfigProps.state,
layerId,
activeVisualization,
]
);
const columnLabelMap =
!layerDatasource && activeVisualization.getUniqueLabels
? activeVisualization.getUniqueLabels(props.visualizationState)
: layerDatasource?.uniqueLabels?.(layerDatasourceConfigProps?.state);
const isEmptyLayer = !groups.some((d) => d.accessors.length > 0);
const isEmptyLayer = !dimensionGroups.some((d) => d.accessors.length > 0);
const { activeId, activeGroup } = activeDimension;
const allAccessors = groups.flatMap((group) =>
const allAccessors = dimensionGroups.flatMap((group) =>
group.accessors.map((accessor) => accessor.columnId)
);
@ -188,16 +187,17 @@ export function LayerPanel(
layerDatasource?.onDrop({
state: layerDatasourceState,
setState: (newState: unknown) => {
updateDatasource(datasourceId, newState);
// we don't sync linked dimension here because that would trigger an onDrop routine within an onDrop routine
updateDatasource(datasourceId, newState, true);
},
source,
target: {
...(target as unknown as DragDropOperation),
filterOperations:
groups.find(({ groupId: gId }) => gId === target.groupId)?.filterOperations ||
Boolean,
dimensionGroups.find(({ groupId: gId }) => gId === target.groupId)
?.filterOperations || Boolean,
},
dimensionGroups: groups,
targetLayerDimensionGroups: dimensionGroups,
dropType,
indexPatterns: framePublicAPI.dataViews.indexPatterns,
})
@ -214,24 +214,31 @@ export function LayerPanel(
target,
source,
dropType,
group: groups.find(({ groupId: gId }) => gId === target.groupId),
group: dimensionGroups.find(({ groupId: gId }) => gId === target.groupId),
},
activeVisualization
)
);
if (isOperation(source) && shouldRemoveSource(source, dropType)) {
props.onRemoveDimension({
columnId: source.columnId,
layerId: source.layerId,
});
}
}
};
}, [
layerDatasource,
setNextFocusedButtonId,
layerDatasourceState,
groups,
dimensionGroups,
framePublicAPI,
updateDatasource,
datasourceId,
activeVisualization,
updateVisualization,
props.visualizationState,
framePublicAPI,
props,
]);
const isDimensionPanelOpen = Boolean(activeId);
@ -260,16 +267,8 @@ export function LayerPanel(
// The datasource can indicate that the previously-valid column is no longer
// complete, which clears the visualization. This keeps the flyout open and reuses
// the previous columnId
updateAll(
datasourceId,
newState,
activeVisualization.removeDimension({
layerId,
columnId: activeId,
prevState: visualizationState,
frame: framePublicAPI,
})
);
props.updateDatasource(datasourceId, newState);
props.onRemoveDimension({ layerId, columnId: activeId });
}
} else if (isDimensionComplete) {
updateAll(
@ -327,7 +326,7 @@ export function LayerPanel(
isOnlyLayer,
isTextBasedLanguage,
onCloneLayer,
onRemoveLayer,
onRemoveLayer: () => onRemoveLayer(layerId),
}),
].filter((i) => i.isCompatible),
[
@ -402,7 +401,7 @@ export function LayerPanel(
)}
</header>
{groups.map((group, groupIndex) => {
{dimensionGroups.map((group, groupIndex) => {
let errorText: string = '';
if (!isEmptyLayer) {
@ -529,32 +528,7 @@ export function LayerPanel(
});
}}
onRemoveClick={(id: string) => {
if (datasourceId && layerDatasource) {
props.updateAll(
datasourceId,
layerDatasource.removeColumn({
layerId,
columnId: id,
prevState: layerDatasourceState,
indexPatterns: dataViews.indexPatterns,
}),
activeVisualization.removeDimension({
layerId,
columnId: id,
prevState: props.visualizationState,
frame: framePublicAPI,
})
);
} else {
props.updateVisualization(
activeVisualization.removeDimension({
layerId,
columnId: id,
prevState: props.visualizationState,
frame: framePublicAPI,
})
);
}
props.onRemoveDimension({ columnId: id, layerId });
removeButtonRef(id);
}}
invalid={
@ -694,7 +668,7 @@ export function LayerPanel(
groupId: activeGroup.groupId,
hideGrouping: activeGroup.hideGrouping,
filterOperations: activeGroup.filterOperations,
dimensionGroups: groups,
dimensionGroups,
toggleFullscreen,
isFullscreen,
setState: updateDataLayerState,
@ -723,7 +697,10 @@ export function LayerPanel(
...layerVisualizationConfigProps,
groupId: activeGroup.groupId,
accessor: activeId,
datasource,
setState: props.updateVisualization,
addLayer: props.addLayer,
removeLayer: props.onRemoveLayer,
panelRef,
}}
/>
@ -735,7 +712,10 @@ export function LayerPanel(
...layerVisualizationConfigProps,
groupId: activeGroup.groupId,
accessor: activeId,
datasource,
setState: props.updateVisualization,
addLayer: props.addLayer,
removeLayer: props.onRemoveLayer,
panelRef,
}}
/>

View file

@ -27,6 +27,7 @@ describe('Data Panel Wrapper', () => {
activeDatasource: {
renderDataPanel,
getUsedDataViews: jest.fn(),
getLayers: jest.fn(() => []),
} as unknown as Datasource,
};

View file

@ -306,7 +306,7 @@ describe('editor_frame', () => {
setDatasourceState(updatedState);
});
expect(mockVisualization.getConfiguration).toHaveBeenCalledTimes(2);
expect(mockVisualization.getConfiguration).toHaveBeenCalledTimes(3);
expect(mockVisualization.getConfiguration).toHaveBeenLastCalledWith(
expect.objectContaining({
state: updatedState,
@ -377,6 +377,7 @@ describe('editor_frame', () => {
getFilters: jest.fn(),
getMaxPossibleNumValues: jest.fn(),
isTextBasedLanguage: jest.fn(() => false),
hasDefaultTimeField: jest.fn(() => true),
};
mockDatasource.getPublicAPI.mockReturnValue(updatedPublicAPI);
@ -386,7 +387,7 @@ describe('editor_frame', () => {
setDatasourceState({});
});
expect(mockVisualization.getConfiguration).toHaveBeenCalledTimes(2);
expect(mockVisualization.getConfiguration).toHaveBeenCalledTimes(3);
expect(mockVisualization.getConfiguration).toHaveBeenLastCalledWith(
expect.objectContaining({
frame: expect.objectContaining({
@ -711,7 +712,7 @@ describe('editor_frame', () => {
instance.find('[data-test-subj="lnsSuggestion"]').at(2).simulate('click');
});
expect(mockVisualization.getConfiguration).toHaveBeenCalledTimes(1);
expect(mockVisualization.getConfiguration).toHaveBeenCalledTimes(2);
expect(mockVisualization.getConfiguration).toHaveBeenLastCalledWith(
expect.objectContaining({
state: suggestionVisState,

View file

@ -571,6 +571,7 @@ describe('suggestion helpers', () => {
getFilters: jest.fn(),
getMaxPossibleNumValues: jest.fn(),
isTextBasedLanguage: jest.fn(() => false),
hasDefaultTimeField: jest.fn(() => true),
},
},
{ activeId: 'testVis', state: {} },
@ -609,6 +610,7 @@ describe('suggestion helpers', () => {
getFilters: jest.fn(),
getMaxPossibleNumValues: jest.fn(),
isTextBasedLanguage: jest.fn(() => false),
hasDefaultTimeField: jest.fn(() => true),
},
};
defaultParams[3] = {
@ -672,6 +674,7 @@ describe('suggestion helpers', () => {
getFilters: jest.fn(),
getMaxPossibleNumValues: jest.fn(),
isTextBasedLanguage: jest.fn(() => false),
hasDefaultTimeField: jest.fn(() => true),
},
};
mockVisualization1.getSuggestions.mockReturnValue([]);

View file

@ -5,7 +5,6 @@
* 2.0.
*/
import { LayerTypes } from '@kbn/expression-xy-plugin/public';
import { LensPlugin } from './plugin';
export type {
@ -38,6 +37,7 @@ export type {
PieVisualizationState,
PieLayerState,
SharedPieLayerState,
LayerType,
} from '../common/types';
export type { DatatableVisualizationState } from './visualizations/datatable/visualization';
@ -80,7 +80,6 @@ export type {
export type {
XYArgs,
XYRender,
LayerType,
LineStyle,
FillStyle,
YScaleType,
@ -105,8 +104,7 @@ export type {
export type { LensEmbeddableInput, LensSavedObjectAttributes, Embeddable } from './embeddable';
/** @deprecated Please use LayerTypes from @kbn/expression-xy-plugin **/
export const layerTypes = LayerTypes;
export { layerTypes } from '../common/layer_types';
export type { LensPublicStart, LensPublicSetup } from './plugin';

View file

@ -21,6 +21,7 @@ export function createMockDatasource(id: string): DatasourceMock {
getFilters: jest.fn(),
getMaxPossibleNumValues: jest.fn(),
isTextBasedLanguage: jest.fn(() => false),
hasDefaultTimeField: jest.fn(() => true),
};
return {
@ -53,6 +54,7 @@ export function createMockDatasource(id: string): DatasourceMock {
getDropProps: jest.fn(),
onDrop: jest.fn(),
createEmptyLayer: jest.fn(),
syncColumns: jest.fn(),
// this is an additional property which doesn't exist on real datasources
// but can be used to validate whether specific API mock functions are called

View file

@ -8,6 +8,7 @@
import { EuiFormRow, EuiIcon, EuiSelect, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { CollapseFunction } from '../../common/expressions';
const options = [
{ text: i18n.translate('xpack.lens.collapse.none', { defaultMessage: 'None' }), value: '' },
@ -22,7 +23,7 @@ export function CollapseSetting({
onChange,
}: {
value: string;
onChange: (value: string) => void;
onChange: (value: CollapseFunction) => void;
}) {
return (
<EuiFormRow
@ -52,7 +53,7 @@ export function CollapseSetting({
options={options}
value={value}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
onChange(e.target.value);
onChange(e.target.value as CollapseFunction);
}}
/>
</EuiFormRow>

View file

@ -44,6 +44,7 @@ export const {
cloneLayer,
addLayer,
setLayerDefaultDimension,
removeDimension,
} = lensActions;
export const makeConfigureStore = (

View file

@ -20,12 +20,20 @@ import {
LensRootStore,
selectTriggerApplyChanges,
selectChangesApplied,
removeDimension,
} from '.';
import { LayerTypes } from '@kbn/expression-xy-plugin/public';
import { makeLensStore, defaultState, mockStoreDeps } from '../mocks';
import { DatasourceMap, VisualizationMap } from '../types';
import {
Datasource,
DatasourceMap,
Visualization,
VisualizationDimensionGroupConfig,
VisualizationMap,
} from '../types';
import { applyChanges, disableAutoApply, enableAutoApply, setChangesApplied } from './lens_slice';
import { LensAppState } from './types';
import { DataViewsState, LensAppState } from './types';
import { layerTypes } from '../../common/layer_types';
describe('lensSlice', () => {
let store: EnhancedStore<{ lens: LensAppState }>;
@ -108,7 +116,7 @@ describe('lensSlice', () => {
})
);
expect(store.getState().lens.visualization.state).toBe(newVisState);
expect(store.getState().lens.visualization.state).toEqual(newVisState);
});
it('should update the datasource state with passed in reducer', () => {
const datasourceUpdater = jest.fn(() => ({ changed: true }));
@ -278,7 +286,12 @@ describe('lensSlice', () => {
),
removeLayer: (layerIds: unknown, layerId: string) =>
(layerIds as string[]).filter((id: string) => id !== layerId),
insertLayer: (layerIds: unknown, layerId: string) => [...(layerIds as string[]), layerId],
insertLayer: (layerIds: unknown, layerId: string, layersToLinkTo: string[]) => [
...(layerIds as string[]),
layerId,
...layersToLinkTo,
],
getCurrentIndexPatternId: jest.fn(() => 'indexPattern1'),
getUsedDataView: jest.fn(() => 'indexPattern1'),
};
};
@ -296,8 +309,10 @@ describe('lensSlice', () => {
testDatasource: testDatasource('testDatasource'),
testDatasource2: testDatasource('testDatasource2'),
};
const activeVisId = 'testVis';
const visualizationMap = {
testVis: {
[activeVisId]: {
clearLayer: (layerIds: unknown, layerId: string) =>
(layerIds as string[]).map((id: string) =>
id === layerId ? `vis_clear_${layerId}` : id
@ -305,9 +320,10 @@ describe('lensSlice', () => {
removeLayer: (layerIds: unknown, layerId: string) =>
(layerIds as string[]).filter((id: string) => id !== layerId),
getLayerIds: (layerIds: unknown) => layerIds as string[],
getLayersToLinkTo: (state, newLayerId) => ['linked-layer-id'],
appendLayer: (layerIds: unknown, layerId: string) => [...(layerIds as string[]), layerId],
getSupportedLayers: jest.fn(() => [{ type: LayerTypes.DATA, label: 'Data Layer' }]),
},
getSupportedLayers: jest.fn(() => [{ type: layerTypes.DATA, label: 'Data Layer' }]),
} as Partial<Visualization>,
};
let customStore: LensRootStore;
@ -317,12 +333,12 @@ describe('lensSlice', () => {
activeDatasourceId: 'testDatasource',
datasourceStates,
visualization: {
activeId: 'testVis',
activeId: activeVisId,
state: ['layer1', 'layer2'],
},
stagedPreview: {
visualization: {
activeId: 'testVis',
activeId: activeVisId,
state: ['layer1', 'layer2'],
},
datasourceStates,
@ -345,11 +361,112 @@ describe('lensSlice', () => {
const state = customStore.getState().lens;
expect(state.visualization.state).toEqual(['layer1', 'layer2', 'foo']);
expect(state.datasourceStates.testDatasource.state).toEqual(['layer1', 'foo']);
expect(state.datasourceStates.testDatasource.state).toEqual([
'layer1',
'foo',
'linked-layer-id',
]);
expect(state.datasourceStates.testDatasource2.state).toEqual(['layer2']);
expect(state.stagedPreview).not.toBeDefined();
});
it('addLayer: syncs linked dimensions', () => {
const activeVisualization = visualizationMap[activeVisId];
activeVisualization.getLinkedDimensions = jest.fn(() => [
{
from: {
layerId: 'from-layer',
columnId: 'from-column',
groupId: 'from-group',
},
to: {
layerId: 'from-layer',
columnId: 'from-column',
groupId: 'from-group',
},
},
]);
activeVisualization.getConfiguration = jest.fn(() => ({
groups: [{ groupId: 'to-group' } as VisualizationDimensionGroupConfig],
}));
activeVisualization.onDrop = jest.fn(({ prevState }) => prevState);
(datasourceMap.testDatasource as unknown as Datasource).syncColumns = jest.fn(
({ state }) => state
);
customStore.dispatch(
addLayer({
layerId: 'foo',
layerType: layerTypes.DATA,
})
);
expect(
(
(datasourceMap.testDatasource as unknown as Datasource).syncColumns as jest.Mock<
Datasource['syncColumns']
>
).mock.calls[0][0]
).toMatchInlineSnapshot(`
Object {
"getDimensionGroups": [Function],
"indexPatterns": Object {},
"links": Array [
Object {
"from": Object {
"columnId": "from-column",
"groupId": "from-group",
"layerId": "from-layer",
},
"to": Object {
"columnId": "from-column",
"groupId": "from-group",
"layerId": "from-layer",
},
},
],
"state": Array [
"layer1",
"foo",
"linked-layer-id",
],
}
`);
expect(activeVisualization.onDrop).toHaveBeenCalledTimes(1);
expect({
...(activeVisualization.onDrop as jest.Mock<Visualization['onDrop']>).mock.calls[0][0],
frame: undefined,
}).toMatchInlineSnapshot(`
Object {
"dropType": "duplicate_compatible",
"frame": undefined,
"group": undefined,
"prevState": Array [
"layer1",
"layer2",
"foo",
],
"source": Object {
"columnId": "from-column",
"groupId": "from-group",
"humanData": Object {
"label": "",
},
"id": "from-column",
"layerId": "from-layer",
},
"target": Object {
"columnId": "from-column",
"filterOperations": [Function],
"groupId": "from-group",
"layerId": "from-layer",
},
}
`);
});
it('removeLayer: should remove the layer if it is not the only layer', () => {
customStore.dispatch(
removeOrClearLayer({
@ -366,5 +483,155 @@ describe('lensSlice', () => {
expect(state.stagedPreview).not.toBeDefined();
});
});
describe('removing a dimension', () => {
const colToRemove = 'col-id';
const otherCol = 'other-col-id';
const datasourceId = 'testDatasource';
interface DatasourceState {
cols: string[];
}
const datasourceStates = {
[datasourceId]: {
isLoading: false,
state: {
cols: [colToRemove, otherCol],
} as DatasourceState,
},
};
const datasourceMap = {
[datasourceId]: {
id: datasourceId,
removeColumn: jest.fn(({ prevState: state, columnId }) => ({
...(state as DatasourceState),
cols: (state as DatasourceState).cols.filter((id) => id !== columnId),
})),
getLayers: () => [],
} as Partial<Datasource>,
};
const activeVisId = 'testVis';
const visualizationMap = {
[activeVisId]: {
removeDimension: jest.fn(({ prevState, columnId }) =>
(prevState as string[]).filter((id) => id !== columnId)
),
} as Partial<Visualization>,
};
const visualizationState = [colToRemove, otherCol];
const dataViews = { indexPatterns: {} } as DataViewsState;
const layerId = 'some-layer-id';
let customStore: LensRootStore;
beforeEach(() => {
customStore = makeLensStore({
preloadedState: {
activeDatasourceId: datasourceId,
datasourceStates,
visualization: {
activeId: activeVisId,
state: visualizationState,
},
dataViews,
} as Partial<LensAppState>,
storeDeps: mockStoreDeps({
visualizationMap: visualizationMap as unknown as VisualizationMap,
datasourceMap: datasourceMap as unknown as DatasourceMap,
}),
}).store;
jest.clearAllMocks();
});
it('removes a dimension', () => {
customStore.dispatch(
removeDimension({
layerId,
columnId: colToRemove,
datasourceId,
})
);
const state = customStore.getState().lens;
expect(datasourceMap[datasourceId].removeColumn).toHaveBeenCalledWith({
layerId,
columnId: colToRemove,
prevState: datasourceStates[datasourceId].state,
indexPatterns: dataViews.indexPatterns,
});
expect(visualizationMap[activeVisId].removeDimension).toHaveBeenCalledWith(
expect.objectContaining({
layerId,
columnId: colToRemove,
prevState: visualizationState,
})
);
expect(state.visualization.state).toEqual([otherCol]);
expect((state.datasourceStates[datasourceId].state as DatasourceState).cols).toEqual([
otherCol,
]);
});
it('removes a dimension without touching the datasource', () => {
customStore.dispatch(
removeDimension({
layerId,
columnId: colToRemove,
datasourceId: undefined,
})
);
const state = customStore.getState().lens;
expect(datasourceMap[datasourceId].removeColumn).not.toHaveBeenCalled();
expect(visualizationMap[activeVisId].removeDimension).toHaveBeenCalledWith(
expect.objectContaining({
layerId,
columnId: colToRemove,
prevState: visualizationState,
})
);
expect(state.visualization.state).toEqual([otherCol]);
});
it('removes linked dimensions', () => {
visualizationMap[activeVisId].getLinkedDimensions = jest.fn(() => [
{
from: {
columnId: colToRemove,
layerId,
groupId: '',
},
to: {
columnId: otherCol,
layerId,
groupId: '',
},
},
]);
customStore.dispatch(
removeDimension({
layerId,
columnId: colToRemove,
datasourceId,
})
);
const state = customStore.getState().lens;
expect(state.visualization.state).toEqual([]);
expect((state.datasourceStates[datasourceId].state as DatasourceState).cols).toEqual([]);
});
});
});
});

View file

@ -12,7 +12,13 @@ import { Query } from '@kbn/es-query';
import { History } from 'history';
import { LensEmbeddableInput } from '..';
import { TableInspectorAdapter } from '../editor_frame_service/types';
import type { VisualizeEditorContext, Suggestion, IndexPattern } from '../types';
import type {
VisualizeEditorContext,
Suggestion,
IndexPattern,
VisualizationMap,
DatasourceMap,
} from '../types';
import { getInitialDatasourceId, getResolvedDateRange, getRemoveOperation } from '../utils';
import type { DataViewsState, LensAppState, LensStoreDeps, VisualizationState } from './types';
import type { Datasource, Visualization } from '../types';
@ -21,7 +27,8 @@ import type { LayerType } from '../../common/types';
import { getLayerType } from '../editor_frame_service/editor_frame/config_panel/add_layer';
import { getVisualizeFieldSuggestions } from '../editor_frame_service/editor_frame/suggestion_helpers';
import type { FramePublicAPI, LensEditContextMapping, LensEditEvent } from '../types';
import { selectFramePublicAPI } from './selectors';
import { selectDataViews, selectFramePublicAPI } from './selectors';
import { onDropForVisualization } from '../editor_frame_service/editor_frame/config_panel/buttons/drop_targets_utils';
export const initialState: LensAppState = {
persistedDoc: undefined,
@ -106,6 +113,7 @@ export const updateDatasourceState = createAction<{
updater: unknown | ((prevState: unknown) => unknown);
datasourceId: string;
clearStagedPreview?: boolean;
dontSyncLinkedDimensions?: boolean;
}>('lens/updateDatasourceState');
export const updateVisualizationState = createAction<{
visualizationId: string;
@ -200,6 +208,11 @@ export const changeIndexPattern = createAction<{
layerId?: string;
dataViews: Partial<DataViewsState>;
}>('lens/changeIndexPattern');
export const removeDimension = createAction<{
layerId: string;
columnId: string;
datasourceId?: string;
}>('lens/removeDimension');
export const lensActions = {
setState,
@ -231,6 +244,8 @@ export const lensActions = {
updateIndexPatterns,
replaceIndexpattern,
changeIndexPattern,
removeDimension,
syncLinkedDimensions,
};
export const makeLensReducer = (storeDeps: LensStoreDeps) => {
@ -286,7 +301,31 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => {
};
}
) => {
const newState = updater(current(state) as LensAppState);
let newState: LensAppState = updater(current(state) as LensAppState);
if (newState.activeDatasourceId) {
const { datasourceState, visualizationState } = syncLinkedDimensions(
newState,
visualizationMap,
datasourceMap
);
newState = {
...newState,
visualization: {
...newState.visualization,
state: visualizationState,
},
datasourceStates: {
...newState.datasourceStates,
[newState.activeDatasourceId]: {
...newState.datasourceStates[newState.activeDatasourceId],
state: datasourceState,
},
},
};
}
return {
...newState,
stagedPreview: undefined,
@ -545,22 +584,49 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => {
updater: unknown | ((prevState: unknown) => unknown);
datasourceId: string;
clearStagedPreview?: boolean;
dontSyncLinkedDimensions: boolean;
};
}
) => {
return {
...state,
const currentState = current(state);
const newAppState: LensAppState = {
...currentState,
datasourceStates: {
...state.datasourceStates,
...currentState.datasourceStates,
[payload.datasourceId]: {
state:
typeof payload.updater === 'function'
? payload.updater(current(state).datasourceStates[payload.datasourceId].state)
? payload.updater(currentState.datasourceStates[payload.datasourceId].state)
: payload.updater,
isLoading: false,
},
},
stagedPreview: payload.clearStagedPreview ? undefined : state.stagedPreview,
stagedPreview: payload.clearStagedPreview ? undefined : currentState.stagedPreview,
};
if (payload.dontSyncLinkedDimensions) {
return newAppState;
}
const {
datasourceState: syncedDatasourceState,
visualizationState: syncedVisualizationState,
} = syncLinkedDimensions(newAppState, visualizationMap, datasourceMap, payload.datasourceId);
return {
...newAppState,
visualization: {
...newAppState.visualization,
state: syncedVisualizationState,
},
datasourceStates: {
...newAppState.datasourceStates,
[payload.datasourceId]: {
state: syncedDatasourceState,
isLoading: false,
},
},
};
},
[updateVisualizationState.type]: (
@ -583,13 +649,21 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => {
if (state.visualization.activeId !== payload.visualizationId) {
return state;
}
return {
...state,
visualization: {
...state.visualization,
state: payload.newState,
},
};
state.visualization.state = payload.newState;
if (!state.activeDatasourceId) {
return;
}
// TODO - consolidate into applySyncLinkedDimensions
const {
datasourceState: syncedDatasourceState,
visualizationState: syncedVisualizationState,
} = syncLinkedDimensions(current(state), visualizationMap, datasourceMap);
state.datasourceStates[state.activeDatasourceId].state = syncedDatasourceState;
state.visualization.state = syncedVisualizationState;
},
[switchVisualization.type]: (
@ -945,11 +1019,15 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => {
.getSupportedLayers(visualizationState, framePublicAPI)
.find(({ type }) => type === layerType) || {};
const layersToLinkTo =
activeVisualization.getLayersToLinkTo?.(visualizationState, layerId) ?? [];
const datasourceState =
!noDatasource && activeDatasource
? activeDatasource.insertLayer(
state.datasourceStates[state.activeDatasourceId].state,
layerId
layerId,
layersToLinkTo
)
: state.datasourceStates[state.activeDatasourceId].state;
@ -966,6 +1044,14 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => {
state.visualization.state = activeVisualizationState;
state.datasourceStates[state.activeDatasourceId].state = activeDatasourceState;
state.stagedPreview = undefined;
const {
datasourceState: syncedDatasourceState,
visualizationState: syncedVisualizationState,
} = syncLinkedDimensions(current(state), visualizationMap, datasourceMap);
state.datasourceStates[state.activeDatasourceId].state = syncedDatasourceState;
state.visualization.state = syncedVisualizationState;
},
[setLayerDefaultDimension.type]: (
state,
@ -1001,6 +1087,72 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => {
state.visualization.state = activeVisualizationState;
state.datasourceStates[state.activeDatasourceId].state = activeDatasourceState;
},
[removeDimension.type]: (
state,
{
payload: { layerId, columnId, datasourceId },
}: {
payload: {
layerId: string;
columnId: string;
datasourceId?: string;
};
}
) => {
if (!state.visualization.activeId) {
return state;
}
const activeVisualization = visualizationMap[state.visualization.activeId];
const links = activeVisualization.getLinkedDimensions?.(state.visualization.state);
const linkedDimensions = links
?.filter(({ from: { columnId: fromId } }) => columnId === fromId)
?.map(({ to }) => to);
const datasource = datasourceId ? datasourceMap[datasourceId] : undefined;
const frame = selectFramePublicAPI({ lens: current(state) }, datasourceMap);
const remove = (dimensionProps: { layerId: string; columnId: string }) => {
if (datasource && datasourceId) {
let datasourceState;
try {
datasourceState = current(state.datasourceStates[datasourceId].state);
} catch {
datasourceState = state.datasourceStates[datasourceId].state;
}
state.datasourceStates[datasourceId].state = datasource?.removeColumn({
layerId: dimensionProps.layerId,
columnId: dimensionProps.columnId,
prevState: datasourceState,
indexPatterns: frame.dataViews.indexPatterns,
});
}
let visualizationState;
try {
visualizationState = current(state.visualization.state);
} catch {
visualizationState = state.visualization.state;
}
state.visualization.state = activeVisualization.removeDimension({
layerId: dimensionProps.layerId,
columnId: dimensionProps.columnId,
prevState: visualizationState,
frame,
});
};
remove({ layerId, columnId });
linkedDimensions?.forEach(
(linkedDimension) =>
linkedDimension.columnId && // if there's no columnId, there's no dimension to remove
remove({ columnId: linkedDimension.columnId, layerId: linkedDimension.layerId })
);
},
});
};
@ -1053,6 +1205,11 @@ function addInitialValueIfAvailable({
{
...info,
columnId: columnId || info.columnId,
visualizationGroups: activeVisualization.getConfiguration({
layerId,
frame: framePublicAPI,
state: activeVisualizationState,
}).groups,
}
),
activeVisualizationState,
@ -1071,3 +1228,76 @@ function addInitialValueIfAvailable({
activeVisualizationState: visualizationState,
};
}
function syncLinkedDimensions(
state: LensAppState,
visualizationMap: VisualizationMap,
datasourceMap: DatasourceMap,
_datasourceId?: string
) {
const datasourceId = _datasourceId ?? state.activeDatasourceId;
if (!datasourceId) {
return { datasourceState: null, visualizationState: state.visualization.state };
}
const indexPatterns = selectDataViews({ lens: state }).indexPatterns;
let datasourceState: unknown = state.datasourceStates[datasourceId].state;
let visualizationState: unknown = state.visualization.state;
const activeVisualization = visualizationMap[state.visualization.activeId!]; // TODO - double check the safety of this coercion
const linkedDimensions = activeVisualization.getLinkedDimensions?.(visualizationState);
const frame = selectFramePublicAPI({ lens: state }, datasourceMap);
const getDimensionGroups = (layerId: string) =>
activeVisualization.getConfiguration({
state: visualizationState,
layerId,
frame,
}).groups;
if (linkedDimensions) {
const idAssuredLinks = linkedDimensions.map((link) => ({
...link,
to: { ...link.to, columnId: link.to.columnId ?? generateId() },
}));
datasourceState = datasourceMap[datasourceId].syncColumns({
state: datasourceState,
links: idAssuredLinks,
getDimensionGroups,
indexPatterns,
});
idAssuredLinks.forEach(({ from, to }) => {
const dropSource = {
...from,
id: from.columnId,
// don't need to worry about accessibility here
humanData: { label: '' },
};
const dropTarget = {
...to,
filterOperations: () => true,
};
visualizationState = (activeVisualization.onDrop || onDropForVisualization)?.(
{
prevState: visualizationState,
frame,
target: dropTarget,
source: dropSource,
dropType: 'duplicate_compatible',
group: getDimensionGroups(to.layerId).find(
({ groupId }) => groupId === dropTarget.groupId
),
},
activeVisualization
);
});
}
return { datasourceState, visualizationState };
}

View file

@ -233,6 +233,15 @@ export interface GetDropPropsArgs<T = unknown> {
indexPatterns: IndexPatternMap;
}
interface DimensionLink {
from: { columnId: string; groupId: string; layerId: string };
to: {
columnId?: string;
groupId: string;
layerId: string;
};
}
/**
* Interface for the datasource registry
*/
@ -254,7 +263,7 @@ export interface Datasource<T = unknown, P = unknown> {
getPersistableState: (state: T) => { state: P; savedObjectReferences: SavedObjectReference[] };
getUnifiedSearchErrors?: (state: T) => Error[];
insertLayer: (state: T, newLayerId: string) => T;
insertLayer: (state: T, newLayerId: string, linkToLayers?: string[]) => T;
createEmptyLayer: (indexPatternId: string) => T;
removeLayer: (state: T, layerId: string) => T;
clearLayer: (state: T, layerId: string) => T;
@ -278,10 +287,18 @@ export interface Datasource<T = unknown, P = unknown> {
value: {
columnId: string;
groupId: string;
visualizationGroups: VisualizationDimensionGroupConfig[];
staticValue?: unknown;
autoTimeField?: boolean;
}
) => T;
syncColumns: (args: {
state: T;
links: Array<DimensionLink & { to: { columnId: string } }>;
getDimensionGroups: (layerId: string) => VisualizationDimensionGroupConfig[];
indexPatterns: IndexPatternMap;
}) => T;
getSelectedFields?: (state: T) => string[];
renderDataPanel: (
@ -489,6 +506,7 @@ export interface DatasourcePublicAPI {
* or 6 if the "Other" bucket is enabled)
*/
getMaxPossibleNumValues: (columnId: string) => number | null;
hasDefaultTimeField: () => boolean;
}
export interface DatasourceDataPanelProps<T = unknown> {
@ -619,7 +637,7 @@ export interface DatasourceDimensionDropProps<T> {
forceRender?: boolean;
}
>;
dimensionGroups: VisualizationDimensionGroupConfig[];
targetLayerDimensionGroups: VisualizationDimensionGroupConfig[];
}
export type DatasourceDimensionDropHandlerProps<S> = DatasourceDimensionDropProps<S> & {
@ -675,6 +693,7 @@ export interface OperationMetadata {
*/
export interface OperationDescriptor extends Operation {
hasTimeShift: boolean;
hasReducedTimeRange: boolean;
}
export interface VisualizationConfigProps<T = unknown> {
@ -699,7 +718,10 @@ export interface VisualizationToolbarProps<T = unknown> {
export type VisualizationDimensionEditorProps<T = unknown> = VisualizationConfigProps<T> & {
groupId: string;
accessor: string;
datasource: DatasourcePublicAPI | undefined;
setState(newState: T | ((currState: T) => T)): void;
addLayer: (layerType: LayerType) => void;
removeLayer: (layerId: string) => void;
panelRef: MutableRefObject<HTMLDivElement | null>;
};
@ -977,7 +999,9 @@ export interface Visualization<T = unknown, P = unknown> {
columnId: string;
groupId: string;
staticValue?: unknown;
autoTimeField?: boolean;
}>;
canAddViaMenu?: boolean;
}>;
/**
* returns a list of custom actions supported by the visualization layer.
@ -990,6 +1014,17 @@ export interface Visualization<T = unknown, P = unknown> {
) => LayerAction[];
/** returns the type string of the given layer */
getLayerType: (layerId: string, state?: T) => LayerType | undefined;
/**
* Get the layers this one should be linked to (currently that means just keeping the data view in sync)
*/
getLayersToLinkTo?: (state: T, newLayerId: string) => string[];
/**
* Returns a set of dimensions that should be kept in sync
*/
getLinkedDimensions?: (state: T) => DimensionLink[];
/* returns the type of removal operation to perform for the specific layer in the current state */
getRemoveOperation?: (state: T, layerId: string) => 'remove' | 'clear';
@ -997,6 +1032,7 @@ export interface Visualization<T = unknown, P = unknown> {
* For consistency across different visualizations, the dimension configuration UI is standardized
*/
getConfiguration: (props: VisualizationConfigProps<T>) => {
hidden?: boolean;
groups: VisualizationDimensionGroupConfig[];
};
@ -1148,6 +1184,7 @@ export interface Visualization<T = unknown, P = unknown> {
*/
onIndexPatternChange?: (state: T, indexPatternId: string, layerId?: string) => T;
onIndexPatternRename?: (state: T, oldIndexPatternId: string, newIndexPatternId: string) => T;
getLayersToRemoveOnIndexPatternChange?: (state: T) => string[];
/**
* Gets custom display options for showing the visualization.
*/

View file

@ -74,6 +74,9 @@ describe('data table dimension editor', () => {
setState,
paletteService: chartPluginMock.createPaletteRegistry(),
panelRef: React.createRef(),
addLayer: jest.fn(),
removeLayer: jest.fn(),
datasource: {} as DatasourcePublicAPI,
};
});

View file

@ -98,7 +98,7 @@ export function TableDimensionEditor(
{props.groupId === 'rows' && (
<CollapseSetting
value={column.collapseFn || ''}
onChange={(collapseFn: string) => {
onChange={(collapseFn) => {
setState({
...state,
columns: updateColumnWith(state, accessor, { collapseFn }),

View file

@ -8,7 +8,11 @@
import React from 'react';
import { EuiComboBox, EuiFieldText } from '@elastic/eui';
import type { PaletteRegistry } from '@kbn/coloring';
import { FramePublicAPI, VisualizationDimensionEditorProps } from '../../../types';
import {
DatasourcePublicAPI,
FramePublicAPI,
VisualizationDimensionEditorProps,
} from '../../../types';
import { DatatableVisualizationState } from '../visualization';
import { createMockDatasource, createMockFramePublicAPI } from '../../../mocks';
import { mountWithIntl } from '@kbn/test-jest-helpers';
@ -67,6 +71,9 @@ describe('data table dimension editor additional section', () => {
setState,
paletteService: chartPluginMock.createPaletteRegistry(),
panelRef: React.createRef(),
addLayer: jest.fn(),
removeLayer: jest.fn(),
datasource: {} as DatasourcePublicAPI,
};
});

View file

@ -526,6 +526,7 @@ describe('Datatable Visualization', () => {
label: 'label',
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
});
const expression = datatableVisualization.toExpression(
@ -577,6 +578,7 @@ describe('Datatable Visualization', () => {
label: 'label',
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
});
const expression = datatableVisualization.toExpression(
@ -712,6 +714,7 @@ describe('Datatable Visualization', () => {
label: 'label',
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
});
const error = datatableVisualization.getErrorMessages({
@ -737,6 +740,7 @@ describe('Datatable Visualization', () => {
label: 'label',
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
});
const error = datatableVisualization.getErrorMessages({

View file

@ -24,6 +24,7 @@ import { act } from 'react-dom/test-utils';
import { PalettePanelContainer } from '../../shared_components';
import { LayerTypes } from '@kbn/expression-xy-plugin/public';
import type { LegacyMetricState } from '../../../common/types';
import { DatasourcePublicAPI } from '../..';
// mocking random id generator function
jest.mock('@elastic/eui', () => {
@ -93,6 +94,9 @@ describe('metric dimension editor', () => {
setState,
paletteService: chartPluginMock.createPaletteRegistry(),
panelRef: React.createRef(),
addLayer: jest.fn(),
removeLayer: jest.fn(),
datasource: {} as DatasourcePublicAPI,
};
// add a div to the ref
props.panelRef.current = document.createElement('div');

View file

@ -271,6 +271,7 @@ describe('metric_visualization', () => {
label: 'shazm',
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
};
},
};

View file

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`metric visualization dimension groups configuration generates configuration 1`] = `
exports[`metric visualization dimension groups configuration primary layer generates configuration 1`] = `
Object {
"groups": Array [
Object {
@ -20,7 +20,6 @@ Object {
},
"groupId": "metric",
"groupLabel": "Primary metric",
"layerId": "first",
"paramEditorCustomProps": Object {
"headingLabel": "Value",
},
@ -42,11 +41,9 @@ Object {
},
"groupId": "secondaryMetric",
"groupLabel": "Secondary metric",
"layerId": "first",
"paramEditorCustomProps": Object {
"headingLabel": "Value",
},
"requiredMinDimensionCount": 0,
"supportsMoreColumns": false,
},
Object {
@ -65,12 +62,10 @@ Object {
"groupId": "max",
"groupLabel": "Maximum value",
"groupTooltip": "If the maximum value is specified, the minimum value is fixed at zero.",
"layerId": "first",
"paramEditorCustomProps": Object {
"headingLabel": "Value",
},
"prioritizedOperation": "max",
"requiredMinDimensionCount": 0,
"supportStaticValue": true,
"supportsMoreColumns": false,
},
@ -90,15 +85,13 @@ Object {
},
"groupId": "breakdownBy",
"groupLabel": "Break down by",
"layerId": "first",
"requiredMinDimensionCount": 0,
"supportsMoreColumns": false,
},
],
}
`;
exports[`metric visualization dimension groups configuration operation filtering breakdownBy supports correct operations 1`] = `
exports[`metric visualization dimension groups configuration primary layer operation filtering breakdownBy supports correct operations 1`] = `
Array [
Object {
"dataType": "number",
@ -111,7 +104,7 @@ Array [
]
`;
exports[`metric visualization dimension groups configuration operation filtering max supports correct operations 1`] = `
exports[`metric visualization dimension groups configuration primary layer operation filtering max supports correct operations 1`] = `
Array [
Object {
"dataType": "number",
@ -120,7 +113,7 @@ Array [
]
`;
exports[`metric visualization dimension groups configuration operation filtering metric supports correct operations 1`] = `
exports[`metric visualization dimension groups configuration primary layer operation filtering metric supports correct operations 1`] = `
Array [
Object {
"dataType": "number",
@ -129,7 +122,7 @@ Array [
]
`;
exports[`metric visualization dimension groups configuration operation filtering secondaryMetric supports correct operations 1`] = `
exports[`metric visualization dimension groups configuration primary layer operation filtering secondaryMetric supports correct operations 1`] = `
Array [
Object {
"dataType": "number",
@ -137,3 +130,63 @@ Array [
},
]
`;
exports[`metric visualization dimension groups configuration trendline layer generates configuration 1`] = `
Object {
"groups": Array [
Object {
"accessors": Array [
Object {
"columnId": "trendline-metric-col-id",
},
],
"filterOperations": [Function],
"groupId": "trendMetric",
"groupLabel": "Primary metric",
"hideGrouping": true,
"nestingOrder": 3,
"supportsMoreColumns": false,
},
Object {
"accessors": Array [
Object {
"columnId": "trendline-secondary-metric-col-id",
},
],
"filterOperations": [Function],
"groupId": "trendSecondaryMetric",
"groupLabel": "Secondary metric",
"hideGrouping": true,
"nestingOrder": 2,
"supportsMoreColumns": false,
},
Object {
"accessors": Array [
Object {
"columnId": "trendline-time-col-id",
},
],
"filterOperations": [Function],
"groupId": "trendTime",
"groupLabel": "Time field",
"hideGrouping": true,
"nestingOrder": 1,
"supportsMoreColumns": false,
},
Object {
"accessors": Array [
Object {
"columnId": "trendline-breakdown-col-id",
},
],
"filterOperations": [Function],
"groupId": "trendBreakdownBy",
"groupLabel": "Break down by",
"hideGrouping": true,
"nestingOrder": 0,
"supportsMoreColumns": false,
},
],
"hidden": true,
}
`;

View file

@ -12,4 +12,8 @@ export const GROUP_ID = {
SECONDARY_METRIC: 'secondaryMetric',
MAX: 'max',
BREAKDOWN_BY: 'breakdownBy',
TREND_METRIC: 'trendMetric',
TREND_SECONDARY_METRIC: 'trendSecondaryMetric',
TREND_TIME: 'trendTime',
TREND_BREAKDOWN_BY: 'trendBreakdownBy',
} as const;

View file

@ -8,14 +8,14 @@
/* eslint-disable max-classes-per-file */
import React, { FormEvent } from 'react';
import { VisualizationDimensionEditorProps } from '../../types';
import { OperationDescriptor, VisualizationDimensionEditorProps } from '../../types';
import { CustomPaletteParams, PaletteOutput, PaletteRegistry } from '@kbn/coloring';
import { MetricVisualizationState } from './visualization';
import { DimensionEditor } from './dimension_editor';
import { DimensionEditor, SupportingVisType } from './dimension_editor';
import { HTMLAttributes, mount, ReactWrapper, shallow } from 'enzyme';
import { CollapseSetting } from '../../shared_components/collapse_setting';
import { EuiButtonGroup, EuiColorPicker } from '@elastic/eui';
import { EuiButtonGroup, EuiColorPicker, PropsOf } from '@elastic/eui';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { LayoutDirection } from '@elastic/charts';
import { act } from 'react-dom/test-utils';
@ -24,6 +24,8 @@ import { createMockFramePublicAPI } from '../../mocks';
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
import { euiLightVars } from '@kbn/ui-theme';
import { DebouncedInput } from '../../shared_components/debounced_input';
import { DatasourcePublicAPI } from '../..';
import { CollapseFunction } from '../../../common/expressions';
jest.mock('lodash', () => {
const original = jest.requireActual('lodash');
@ -37,9 +39,14 @@ jest.mock('lodash', () => {
const SELECTORS = {
PRIMARY_METRIC_EDITOR: '[data-test-subj="lnsMetricDimensionEditor_primary_metric"]',
SECONDARY_METRIC_EDITOR: '[data-test-subj="lnsMetricDimensionEditor_secondary_metric"]',
MAX_EDITOR: '[data-test-subj="lnsMetricDimensionEditor_maximum"]',
BREAKDOWN_EDITOR: '[data-test-subj="lnsMetricDimensionEditor_breakdown"]',
};
// see https://github.com/facebook/jest/issues/4402#issuecomment-534516219
const expectCalledBefore = (mock1: jest.Mock, mock2: jest.Mock) =>
expect(mock1.mock.invocationCallOrder[0]).toBeLessThan(mock2.mock.invocationCallOrder[0]);
describe('dimension editor', () => {
const palette: PaletteOutput<CustomPaletteParams> = {
type: 'palette',
@ -63,6 +70,13 @@ describe('dimension editor', () => {
maxCols: 5,
color: 'static-color',
palette,
showBar: true,
trendlineLayerId: 'second',
trendlineLayerType: 'metricTrendline',
trendlineMetricAccessor: 'trendline-metric-col-id',
trendlineSecondaryMetricAccessor: 'trendline-secondary-metric-accessor',
trendlineTimeAccessor: 'trendline-time-col-id',
trendlineBreakdownByAccessor: 'trendline-breakdown-col-id',
};
let props: VisualizationDimensionEditorProps<MetricVisualizationState> & {
@ -75,6 +89,14 @@ describe('dimension editor', () => {
groupId: 'some-group',
accessor: 'some-accessor',
state: fullState,
datasource: {
hasDefaultTimeField: jest.fn(),
getOperationForColumnId: jest.fn(() => ({
hasReducedTimeRange: false,
})),
} as unknown as DatasourcePublicAPI,
removeLayer: jest.fn(),
addLayer: jest.fn(),
frame: createMockFramePublicAPI(),
setState: jest.fn(),
panelRef: {} as React.MutableRefObject<HTMLDivElement | null>,
@ -82,8 +104,11 @@ describe('dimension editor', () => {
};
});
afterEach(() => jest.clearAllMocks());
describe('primary metric dimension', () => {
const accessor = 'primary-metric-col-id';
const metricAccessorState = { ...fullState, metricAccessor: accessor };
beforeEach(() => {
props.frame.activeData = {
@ -129,16 +154,53 @@ describe('dimension editor', () => {
this.colorPicker.props().onChange!(color, {} as EuiColorPickerOutput);
});
}
private get supportingVisButtonGroup() {
return this._wrapper.find(
'EuiButtonGroup[data-test-subj="lnsMetric_supporting_visualization_buttons"]'
) as unknown as ReactWrapper<PropsOf<typeof EuiButtonGroup>>;
}
public get currentSupportingVis() {
return this.supportingVisButtonGroup
.props()
.idSelected?.split('--')[1] as SupportingVisType;
}
public isDisabled(type: SupportingVisType) {
return this.supportingVisButtonGroup.props().options.find(({ id }) => id.includes(type))
?.isDisabled;
}
public setSupportingVis(type: SupportingVisType) {
this.supportingVisButtonGroup.props().onChange(`some-id--${type}`);
}
private get progressDirectionControl() {
return this._wrapper.find(
'EuiButtonGroup[data-test-subj="lnsMetric_progress_direction_buttons"]'
) as unknown as ReactWrapper<PropsOf<typeof EuiButtonGroup>>;
}
public get progressDirectionShowing() {
return this.progressDirectionControl.exists();
}
public setProgressDirection(direction: LayoutDirection) {
this.progressDirectionControl.props().onChange(direction);
this._wrapper.update();
}
}
const mockSetState = jest.fn();
const getHarnessWithState = (state: MetricVisualizationState) =>
const getHarnessWithState = (state: MetricVisualizationState, datasource = props.datasource) =>
new Harness(
mountWithIntl(
<DimensionEditor
{...props}
state={{ ...state, metricAccessor: accessor }}
datasource={datasource}
state={state}
setState={mockSetState}
accessor={accessor}
/>
@ -156,21 +218,25 @@ describe('dimension editor', () => {
expect(component.exists(SELECTORS.PRIMARY_METRIC_EDITOR)).toBeTruthy();
expect(component.exists(SELECTORS.SECONDARY_METRIC_EDITOR)).toBeFalsy();
expect(component.exists(SELECTORS.MAX_EDITOR)).toBeFalsy();
expect(component.exists(SELECTORS.BREAKDOWN_EDITOR)).toBeFalsy();
});
describe('static color controls', () => {
it('is hidden when dynamic coloring is enabled', () => {
const harnessWithPalette = getHarnessWithState({ ...fullState, palette });
const harnessWithPalette = getHarnessWithState({ ...metricAccessorState, palette });
expect(harnessWithPalette.colorPicker.exists()).toBeFalsy();
const harnessNoPalette = getHarnessWithState({ ...fullState, palette: undefined });
const harnessNoPalette = getHarnessWithState({
...metricAccessorState,
palette: undefined,
});
expect(harnessNoPalette.colorPicker.exists()).toBeTruthy();
});
it('fills with default value', () => {
const localHarness = getHarnessWithState({
...fullState,
...metricAccessorState,
palette: undefined,
color: undefined,
});
@ -179,7 +245,7 @@ describe('dimension editor', () => {
it('sets color', () => {
const localHarness = getHarnessWithState({
...fullState,
...metricAccessorState,
palette: undefined,
color: 'some-color',
});
@ -200,6 +266,144 @@ describe('dimension editor', () => {
`);
});
});
describe('supporting visualizations', () => {
const stateWOTrend = {
...metricAccessorState,
trendlineLayerId: undefined,
};
describe('reflecting visualization state', () => {
it('should select the correct button', () => {
expect(
getHarnessWithState({ ...stateWOTrend, showBar: false, maxAccessor: undefined })
.currentSupportingVis
).toBe<SupportingVisType>('none');
expect(
getHarnessWithState({ ...stateWOTrend, showBar: true }).currentSupportingVis
).toBe<SupportingVisType>('bar');
expect(
getHarnessWithState(metricAccessorState).currentSupportingVis
).toBe<SupportingVisType>('trendline');
});
it('should disable bar when no max dimension', () => {
expect(
getHarnessWithState({
...stateWOTrend,
showBar: false,
maxAccessor: 'something',
}).isDisabled('bar')
).toBeFalsy();
expect(
getHarnessWithState({
...stateWOTrend,
showBar: false,
maxAccessor: undefined,
}).isDisabled('bar')
).toBeTruthy();
});
it('should disable trendline when no default time field', () => {
expect(
getHarnessWithState(stateWOTrend, {
hasDefaultTimeField: () => false,
getOperationForColumnId: (id) => ({} as OperationDescriptor),
} as DatasourcePublicAPI).isDisabled('trendline')
).toBeTruthy();
expect(
getHarnessWithState(stateWOTrend, {
hasDefaultTimeField: () => true,
getOperationForColumnId: (id) => ({} as OperationDescriptor),
} as DatasourcePublicAPI).isDisabled('trendline')
).toBeFalsy();
});
});
it('should disable trendline when a metric dimension has a reduced time range', () => {
expect(
getHarnessWithState(stateWOTrend, {
hasDefaultTimeField: () => true,
getOperationForColumnId: (id) =>
({ hasReducedTimeRange: id === stateWOTrend.metricAccessor } as OperationDescriptor),
} as DatasourcePublicAPI).isDisabled('trendline')
).toBeTruthy();
expect(
getHarnessWithState(stateWOTrend, {
hasDefaultTimeField: () => true,
getOperationForColumnId: (id) =>
({
hasReducedTimeRange: id === stateWOTrend.secondaryMetricAccessor,
} as OperationDescriptor),
} as DatasourcePublicAPI).isDisabled('trendline')
).toBeTruthy();
});
describe('responding to buttons', () => {
it('enables trendline', () => {
getHarnessWithState(stateWOTrend).setSupportingVis('trendline');
expect(mockSetState).toHaveBeenCalledWith({ ...stateWOTrend, showBar: false });
expect(props.addLayer).toHaveBeenCalledWith('metricTrendline');
expectCalledBefore(mockSetState, props.addLayer as jest.Mock);
});
it('enables bar', () => {
getHarnessWithState(metricAccessorState).setSupportingVis('bar');
expect(mockSetState).toHaveBeenCalledWith({ ...metricAccessorState, showBar: true });
expect(props.removeLayer).toHaveBeenCalledWith(metricAccessorState.trendlineLayerId);
expectCalledBefore(mockSetState, props.removeLayer as jest.Mock);
});
it('selects none from bar', () => {
getHarnessWithState(stateWOTrend).setSupportingVis('none');
expect(mockSetState).toHaveBeenCalledWith({ ...stateWOTrend, showBar: false });
expect(props.removeLayer).not.toHaveBeenCalled();
});
it('selects none from trendline', () => {
getHarnessWithState(metricAccessorState).setSupportingVis('none');
expect(mockSetState).toHaveBeenCalledWith({ ...metricAccessorState, showBar: false });
expect(props.removeLayer).toHaveBeenCalledWith(metricAccessorState.trendlineLayerId);
expectCalledBefore(mockSetState, props.removeLayer as jest.Mock);
});
});
describe('progress bar direction controls', () => {
it('hides direction controls if bar not showing', () => {
expect(
getHarnessWithState({ ...stateWOTrend, showBar: false }).progressDirectionShowing
).toBeFalsy();
});
it('toggles progress direction', () => {
const harness = getHarnessWithState(metricAccessorState);
expect(harness.progressDirectionShowing).toBeTruthy();
expect(harness.currentState.progressDirection).toBe('vertical');
harness.setProgressDirection('horizontal');
harness.setProgressDirection('vertical');
harness.setProgressDirection('horizontal');
expect(mockSetState).toHaveBeenCalledTimes(3);
expect(mockSetState.mock.calls.map((args) => args[0].progressDirection))
.toMatchInlineSnapshot(`
Array [
"horizontal",
"vertical",
"horizontal",
]
`);
});
});
});
});
describe('secondary metric dimension', () => {
@ -234,6 +438,7 @@ describe('dimension editor', () => {
expect(component.exists(SELECTORS.SECONDARY_METRIC_EDITOR)).toBeTruthy();
expect(component.exists(SELECTORS.BREAKDOWN_EDITOR)).toBeFalsy();
expect(component.exists(SELECTORS.MAX_EDITOR)).toBeFalsy();
expect(component.exists(SELECTORS.PRIMARY_METRIC_EDITOR)).toBeFalsy();
});
@ -317,81 +522,21 @@ describe('dimension editor', () => {
});
describe('maximum dimension', () => {
const accessor = 'maximum-col-id';
class Harness {
public _wrapper;
const accessor = 'max-col-id';
constructor(
wrapper: ReactWrapper<HTMLAttributes, unknown, React.Component<{}, {}, unknown>>
) {
this._wrapper = wrapper;
}
private get rootComponent() {
return this._wrapper.find(DimensionEditor);
}
private get progressDirectionControl() {
return this._wrapper.find(EuiButtonGroup);
}
public get currentState() {
return this.rootComponent.props().state;
}
public setProgressDirection(direction: LayoutDirection) {
this.progressDirectionControl.props().onChange(direction);
this._wrapper.update();
}
public get progressDirectionDisabled() {
return this.progressDirectionControl.find(EuiButtonGroup).props().isDisabled;
}
public setMaxCols(max: number) {
act(() => {
this._wrapper.find('EuiFieldNumber[data-test-subj="lnsMetric_max_cols"]').props()
.onChange!({
target: { value: String(max) },
} as unknown as FormEvent);
});
}
}
let harness: Harness;
const mockSetState = jest.fn();
beforeEach(() => {
harness = new Harness(
mountWithIntl(
<DimensionEditor
{...props}
state={{ ...fullState, maxAccessor: accessor }}
accessor={accessor}
setState={mockSetState}
/>
)
it('renders when the accessor matches', () => {
const component = shallow(
<DimensionEditor
{...props}
state={{ ...fullState, maxAccessor: accessor }}
accessor={accessor}
/>
);
});
afterEach(() => mockSetState.mockClear());
it('toggles progress direction', () => {
expect(harness.currentState.progressDirection).toBe('vertical');
harness.setProgressDirection('horizontal');
harness.setProgressDirection('vertical');
harness.setProgressDirection('horizontal');
expect(mockSetState).toHaveBeenCalledTimes(3);
expect(mockSetState.mock.calls.map((args) => args[0].progressDirection))
.toMatchInlineSnapshot(`
Array [
"horizontal",
"vertical",
"horizontal",
]
`);
expect(component.exists(SELECTORS.MAX_EDITOR)).toBeTruthy();
expect(component.exists(SELECTORS.PRIMARY_METRIC_EDITOR)).toBeFalsy();
expect(component.exists(SELECTORS.SECONDARY_METRIC_EDITOR)).toBeFalsy();
expect(component.exists(SELECTORS.BREAKDOWN_EDITOR)).toBeFalsy();
});
});
@ -415,7 +560,7 @@ describe('dimension editor', () => {
return this.collapseSetting.props().value;
}
public setCollapseFn(fn: string) {
public setCollapseFn(fn: CollapseFunction) {
return this.collapseSetting.props().onChange(fn);
}
@ -458,6 +603,7 @@ describe('dimension editor', () => {
expect(component.exists(SELECTORS.BREAKDOWN_EDITOR)).toBeTruthy();
expect(component.exists(SELECTORS.SECONDARY_METRIC_EDITOR)).toBeFalsy();
expect(component.exists(SELECTORS.MAX_EDITOR)).toBeFalsy();
expect(component.exists(SELECTORS.PRIMARY_METRIC_EDITOR)).toBeFalsy();
});

View file

@ -29,7 +29,6 @@ import {
DEFAULT_MIN_STOP,
} from '@kbn/coloring';
import { getDataBoundsForPalette } from '@kbn/expression-metric-vis-plugin/public';
import { css } from '@emotion/react';
import { getColumnByAccessor } from '@kbn/visualizations-plugin/common/utils';
import { isNumericFieldForDatatable } from '../../../common/expressions/datatable/utils';
import {
@ -39,10 +38,17 @@ import {
} from '../../shared_components';
import type { VisualizationDimensionEditorProps } from '../../types';
import { defaultNumberPaletteParams, defaultPercentagePaletteParams } from './palette_config';
import { DEFAULT_MAX_COLUMNS, getDefaultColor, MetricVisualizationState } from './visualization';
import {
DEFAULT_MAX_COLUMNS,
getDefaultColor,
MetricVisualizationState,
showingBar,
} from './visualization';
import { CollapseSetting } from '../../shared_components/collapse_setting';
import { DebouncedInput } from '../../shared_components/debounced_input';
export type SupportingVisType = 'none' | 'bar' | 'trendline';
type Props = VisualizationDimensionEditorProps<MetricVisualizationState> & {
paletteService: PaletteRegistry;
};
@ -117,7 +123,7 @@ function BreakdownByEditor({ setState, state }: SubProps) {
</EuiFormRow>
<CollapseSetting
value={state.collapseFn || ''}
onChange={(collapseFn: string) => {
onChange={(collapseFn) => {
setState({
...state,
collapseFn,
@ -129,49 +135,7 @@ function BreakdownByEditor({ setState, state }: SubProps) {
}
function MaximumEditor({ setState, state, idPrefix }: SubProps) {
return (
<EuiFormRow
label={i18n.translate('xpack.lens.metric.progressDirectionLabel', {
defaultMessage: 'Bar direction',
})}
fullWidth
display="columnCompressed"
>
<EuiButtonGroup
isFullWidth
buttonSize="compressed"
legend={i18n.translate('xpack.lens.metric.progressDirectionLabel', {
defaultMessage: 'Bar direction',
})}
data-test-subj="lnsMetric_progress_direction_buttons"
name="alignment"
options={[
{
id: `${idPrefix}vertical`,
label: i18n.translate('xpack.lens.metric.progressDirection.vertical', {
defaultMessage: 'Vertical',
}),
'data-test-subj': 'lnsMetric_progress_bar_vertical',
},
{
id: `${idPrefix}horizontal`,
label: i18n.translate('xpack.lens.metric.progressDirection.horizontal', {
defaultMessage: 'Horizontal',
}),
'data-test-subj': 'lnsMetric_progress_bar_horizontal',
},
]}
idSelected={`${idPrefix}${state.progressDirection ?? 'vertical'}`}
onChange={(id) => {
const newDirection = id.replace(idPrefix, '') as LayoutDirection;
setState({
...state,
progressDirection: newDirection,
});
}}
/>
</EuiFormRow>
);
return null;
}
function SecondaryMetricEditor({ accessor, idPrefix, frame, layerId, setState, state }: SubProps) {
@ -299,17 +263,176 @@ function PrimaryMetricEditor(props: SubProps) {
const togglePalette = () => setIsPaletteOpen(!isPaletteOpen);
const supportingVisLabel = i18n.translate('xpack.lens.metric.supportingVis.label', {
defaultMessage: 'Supporting visualization',
});
const hasDefaultTimeField = props.datasource?.hasDefaultTimeField();
const metricHasReducedTimeRange = Boolean(
state.metricAccessor &&
props.datasource?.getOperationForColumnId(state.metricAccessor)?.hasReducedTimeRange
);
const secondaryMetricHasReducedTimeRange = Boolean(
state.secondaryMetricAccessor &&
props.datasource?.getOperationForColumnId(state.secondaryMetricAccessor)?.hasReducedTimeRange
);
const supportingVisHelpTexts: string[] = [];
const supportsTrendline =
hasDefaultTimeField && !metricHasReducedTimeRange && !secondaryMetricHasReducedTimeRange;
if (!supportsTrendline) {
supportingVisHelpTexts.push(
!hasDefaultTimeField
? i18n.translate('xpack.lens.metric.supportingVis.needDefaultTimeField', {
defaultMessage: 'Use a data view with a default time field to enable trend lines.',
})
: metricHasReducedTimeRange
? i18n.translate('xpack.lens.metric.supportingVis.metricHasReducedTimeRange', {
defaultMessage:
'Remove the reduced time range on this dimension to enable trend lines.',
})
: secondaryMetricHasReducedTimeRange
? i18n.translate('xpack.lens.metric.supportingVis.secondaryMetricHasReducedTimeRange', {
defaultMessage:
'Remove the reduced time range on the secondary metric dimension to enable trend lines.',
})
: ''
);
}
if (!state.maxAccessor) {
supportingVisHelpTexts.push(
i18n.translate('xpack.lens.metric.summportingVis.needMaxDimension', {
defaultMessage: 'Add a maximum dimension to enable the progress bar.',
})
);
}
const buttonIdPrefix = `${idPrefix}--`;
return (
<>
<EuiFormRow
display="columnCompressed"
fullWidth
label={supportingVisLabel}
helpText={supportingVisHelpTexts.map((text) => (
<div>{text}</div>
))}
>
<EuiButtonGroup
isFullWidth
buttonSize="compressed"
legend={supportingVisLabel}
data-test-subj="lnsMetric_supporting_visualization_buttons"
options={[
{
id: `${buttonIdPrefix}none`,
label: i18n.translate('xpack.lens.metric.supportingVisualization.none', {
defaultMessage: 'None',
}),
'data-test-subj': 'lnsMetric_supporting_visualization_none',
},
{
id: `${buttonIdPrefix}trendline`,
label: i18n.translate('xpack.lens.metric.supportingVisualization.trendline', {
defaultMessage: 'Trend line',
}),
isDisabled: !supportsTrendline,
'data-test-subj': 'lnsMetric_supporting_visualization_trendline',
},
{
id: `${buttonIdPrefix}bar`,
label: i18n.translate('xpack.lens.metric.supportingVisualization.bar', {
defaultMessage: 'Bar',
}),
isDisabled: !state.maxAccessor,
'data-test-subj': 'lnsMetric_supporting_visualization_bar',
},
]}
idSelected={`${buttonIdPrefix}${
state.trendlineLayerId ? 'trendline' : showingBar(state) ? 'bar' : 'none'
}`}
onChange={(id) => {
const supportingVisualizationType = id.split('--')[1] as SupportingVisType;
switch (supportingVisualizationType) {
case 'trendline':
setState({
...state,
showBar: false,
});
props.addLayer('metricTrendline');
break;
case 'bar':
setState({
...state,
showBar: true,
});
if (state.trendlineLayerId) props.removeLayer(state.trendlineLayerId);
break;
case 'none':
setState({
...state,
showBar: false,
});
if (state.trendlineLayerId) props.removeLayer(state.trendlineLayerId);
break;
}
}}
/>
</EuiFormRow>
{showingBar(state) && (
<EuiFormRow
label={i18n.translate('xpack.lens.metric.progressDirectionLabel', {
defaultMessage: 'Bar direction',
})}
fullWidth
display="columnCompressed"
>
<EuiButtonGroup
isFullWidth
buttonSize="compressed"
legend={i18n.translate('xpack.lens.metric.progressDirectionLabel', {
defaultMessage: 'Bar direction',
})}
data-test-subj="lnsMetric_progress_direction_buttons"
name="alignment"
options={[
{
id: `${idPrefix}vertical`,
label: i18n.translate('xpack.lens.metric.progressDirection.vertical', {
defaultMessage: 'Vertical',
}),
'data-test-subj': 'lnsMetric_progress_bar_vertical',
},
{
id: `${idPrefix}horizontal`,
label: i18n.translate('xpack.lens.metric.progressDirection.horizontal', {
defaultMessage: 'Horizontal',
}),
'data-test-subj': 'lnsMetric_progress_bar_horizontal',
},
]}
idSelected={`${idPrefix}${state.progressDirection ?? 'vertical'}`}
onChange={(id) => {
const newDirection = id.replace(idPrefix, '') as LayoutDirection;
setState({
...state,
progressDirection: newDirection,
});
}}
/>
</EuiFormRow>
)}
<EuiFormRow
display="columnCompressed"
fullWidth
label={i18n.translate('xpack.lens.metric.dynamicColoring.label', {
defaultMessage: 'Color mode',
})}
css={css`
align-items: center;
`}
>
<EuiButtonGroup
isFullWidth
@ -362,62 +485,60 @@ function PrimaryMetricEditor(props: SubProps) {
</EuiFormRow>
{!hasDynamicColoring && <StaticColorControls {...props} />}
{hasDynamicColoring && (
<>
<EuiFormRow
display="columnCompressed"
fullWidth
label={i18n.translate('xpack.lens.paletteMetricGradient.label', {
defaultMessage: 'Color',
})}
<EuiFormRow
display="columnCompressed"
fullWidth
label={i18n.translate('xpack.lens.paletteMetricGradient.label', {
defaultMessage: 'Color',
})}
>
<EuiFlexGroup
alignItems="center"
gutterSize="s"
responsive={false}
className="lnsDynamicColoringClickable"
>
<EuiFlexGroup
alignItems="center"
gutterSize="s"
responsive={false}
className="lnsDynamicColoringClickable"
>
<EuiFlexItem>
<EuiColorPaletteDisplay
data-test-subj="lnsMetric_dynamicColoring_palette"
palette={displayStops.map(({ color }) => color)}
type={FIXED_PROGRESSION}
onClick={togglePalette}
<EuiFlexItem>
<EuiColorPaletteDisplay
data-test-subj="lnsMetric_dynamicColoring_palette"
palette={displayStops.map(({ color }) => color)}
type={FIXED_PROGRESSION}
onClick={togglePalette}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="lnsMetric_dynamicColoring_trigger"
iconType="controlsHorizontal"
onClick={togglePalette}
size="xs"
flush="both"
>
{i18n.translate('xpack.lens.paletteTableGradient.customize', {
defaultMessage: 'Edit',
})}
</EuiButtonEmpty>
<PalettePanelContainer
siblingRef={props.panelRef}
isOpen={isPaletteOpen}
handleClose={togglePalette}
>
<CustomizablePalette
palettes={props.paletteService}
activePalette={activePalette}
dataBounds={currentMinMax}
showRangeTypeSelector={supportsPercentPalette}
setPalette={(newPalette) => {
setState({
...state,
palette: newPalette,
});
}}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="lnsMetric_dynamicColoring_trigger"
iconType="controlsHorizontal"
onClick={togglePalette}
size="xs"
flush="both"
>
{i18n.translate('xpack.lens.paletteTableGradient.customize', {
defaultMessage: 'Edit',
})}
</EuiButtonEmpty>
<PalettePanelContainer
siblingRef={props.panelRef}
isOpen={isPaletteOpen}
handleClose={togglePalette}
>
<CustomizablePalette
palettes={props.paletteService}
activePalette={activePalette}
dataBounds={currentMinMax}
showRangeTypeSelector={supportsPercentPalette}
setPalette={(newPalette) => {
setState({
...state,
palette: newPalette,
});
}}
/>
</PalettePanelContainer>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
</>
</PalettePanelContainer>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
)}
</>
);
@ -439,7 +560,7 @@ function StaticColorControls({ state, setState }: Pick<Props, 'state' | 'setStat
useDebouncedValue<string>(
{
onChange: setColor,
value: state.color || getDefaultColor(!!state.maxAccessor),
value: state.color || getDefaultColor(state),
},
{ allowFalsyValue: true }
);

View file

@ -0,0 +1,172 @@
/*
* 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 { CustomPaletteParams, CUSTOM_PALETTE, PaletteRegistry } from '@kbn/coloring';
import {
EXPRESSION_METRIC_NAME,
EXPRESSION_METRIC_TRENDLINE_NAME,
} from '@kbn/expression-metric-vis-plugin/public';
import { buildExpressionFunction } from '@kbn/expressions-plugin/common';
import { Ast } from '@kbn/interpreter';
import { CollapseArgs, CollapseFunction } from '../../../common/expressions';
import { CollapseExpressionFunction } from '../../../common/expressions/collapse/types';
import { DatasourceLayers } from '../../types';
import { showingBar } from './metric_visualization';
import { DEFAULT_MAX_COLUMNS, getDefaultColor, MetricVisualizationState } from './visualization';
// TODO - deduplicate with gauges?
function computePaletteParams(params: CustomPaletteParams) {
return {
...params,
// rewrite colors and stops as two distinct arguments
colors: (params?.stops || []).map(({ color }) => color),
stops: params?.name === 'custom' ? (params?.stops || []).map(({ stop }) => stop) : [],
reverse: false, // managed at UI level
};
}
const getTrendlineExpression = (
state: MetricVisualizationState,
datasourceExpressionsByLayers: Record<string, Ast>
): Ast | undefined => {
if (!state.trendlineLayerId || !state.trendlineMetricAccessor || !state.trendlineTimeAccessor) {
return;
}
const datasourceExpression = datasourceExpressionsByLayers[state.trendlineLayerId];
return {
type: 'expression',
chain: [
{
type: 'function',
function: EXPRESSION_METRIC_TRENDLINE_NAME,
arguments: {
metric: [state.trendlineMetricAccessor],
timeField: [state.trendlineTimeAccessor],
breakdownBy:
state.trendlineBreakdownByAccessor && !state.collapseFn
? [state.trendlineBreakdownByAccessor]
: [],
inspectorTableId: [state.trendlineLayerId],
...(datasourceExpression
? {
table: [
{
...datasourceExpression,
chain: [
...datasourceExpression.chain,
...(state.collapseFn
? [
buildExpressionFunction<CollapseExpressionFunction>('lens_collapse', {
by: [state.trendlineTimeAccessor],
metric: [state.trendlineMetricAccessor],
fn: [state.collapseFn],
}).toAst(),
]
: []),
],
},
],
}
: {}),
},
},
],
};
};
export const toExpression = (
paletteService: PaletteRegistry,
state: MetricVisualizationState,
datasourceLayers: DatasourceLayers,
datasourceExpressionsByLayers: Record<string, Ast> | undefined = {}
): Ast | null => {
if (!state.metricAccessor) {
return null;
}
const datasource = datasourceLayers[state.layerId];
const datasourceExpression = datasourceExpressionsByLayers[state.layerId];
const maxPossibleTiles =
// if there's a collapse function, no need to calculate since we're dealing with a single tile
state.breakdownByAccessor && !state.collapseFn
? datasource?.getMaxPossibleNumValues(state.breakdownByAccessor)
: null;
const getCollapseFnArguments = (): CollapseArgs => {
const metric = [state.metricAccessor, state.secondaryMetricAccessor, state.maxAccessor].filter(
Boolean
) as string[];
const collapseFn = state.collapseFn as CollapseFunction;
const fn = metric.map((accessor) => {
if (accessor !== state.maxAccessor) {
return collapseFn;
} else {
const isMaxStatic = Boolean(
datasource?.getOperationForColumnId(state.maxAccessor!)?.isStaticValue
);
// we do this because the user expects the static value they set to be the same
// even if they define a collapse on the breakdown by
return isMaxStatic ? 'max' : collapseFn;
}
});
return {
by: [],
metric,
fn,
};
};
const collapseExpressionFunction = state.collapseFn
? buildExpressionFunction<CollapseExpressionFunction>(
'lens_collapse',
getCollapseFnArguments()
).toAst()
: undefined;
const trendlineExpression = getTrendlineExpression(state, datasourceExpressionsByLayers);
return {
type: 'expression',
chain: [
...(datasourceExpression?.chain ?? []),
...(collapseExpressionFunction ? [collapseExpressionFunction] : []),
{
type: 'function',
function: EXPRESSION_METRIC_NAME,
arguments: {
metric: state.metricAccessor ? [state.metricAccessor] : [],
secondaryMetric: state.secondaryMetricAccessor ? [state.secondaryMetricAccessor] : [],
secondaryPrefix:
typeof state.secondaryPrefix !== 'undefined' ? [state.secondaryPrefix] : [],
max: showingBar(state) ? [state.maxAccessor] : [],
breakdownBy:
state.breakdownByAccessor && !state.collapseFn ? [state.breakdownByAccessor] : [],
trendline: trendlineExpression ? [trendlineExpression] : [],
subtitle: state.subtitle ? [state.subtitle] : [],
progressDirection: state.progressDirection ? [state.progressDirection] : [],
color: [state.color || getDefaultColor(state)],
palette: state.palette?.params
? [
paletteService
.get(CUSTOM_PALETTE)
.toExpression(computePaletteParams(state.palette.params as CustomPaletteParams)),
]
: [],
maxCols: [state.maxCols ?? DEFAULT_MAX_COLUMNS],
minTiles: maxPossibleTiles ? [maxPossibleTiles] : [],
inspectorTableId: [state.layerId],
},
},
],
};
};

View file

@ -48,6 +48,13 @@ describe('metric toolbar', () => {
maxCols: 5,
color: 'static-color',
palette,
showBar: true,
trendlineLayerId: 'second',
trendlineLayerType: 'metricTrendline',
trendlineMetricAccessor: 'trendline-metric-col-id',
trendlineSecondaryMetricAccessor: 'trendline-secondary-metric-col-id',
trendlineTimeAccessor: 'trendline-time-col-id',
trendlineBreakdownByAccessor: 'trendline-breakdown-col-id',
};
const frame = createMockFramePublicAPI();

View file

@ -21,6 +21,7 @@ import {
import { GROUP_ID } from './constants';
import { getMetricVisualization, MetricVisualizationState } from './visualization';
import { themeServiceMock } from '@kbn/core/public/mocks';
import { Ast } from '@kbn/interpreter';
const paletteService = chartPluginMock.createPaletteRegistry();
const theme = themeServiceMock.createStartContract();
@ -39,7 +40,26 @@ describe('metric visualization', () => {
},
};
const fullState: Required<MetricVisualizationState> = {
const trendlineProps = {
trendlineLayerId: 'second',
trendlineLayerType: 'metricTrendline',
trendlineMetricAccessor: 'trendline-metric-col-id',
trendlineSecondaryMetricAccessor: 'trendline-secondary-metric-col-id',
trendlineTimeAccessor: 'trendline-time-col-id',
trendlineBreakdownByAccessor: 'trendline-breakdown-col-id',
} as const;
const fullState: Required<
Omit<
MetricVisualizationState,
| 'trendlineLayerId'
| 'trendlineLayerType'
| 'trendlineMetricAccessor'
| 'trendlineSecondaryMetricAccessor'
| 'trendlineTimeAccessor'
| 'trendlineBreakdownByAccessor'
>
> = {
layerId: 'first',
layerType: 'data',
metricAccessor: 'metric-col-id',
@ -53,6 +73,12 @@ describe('metric visualization', () => {
maxCols: 5,
color: 'static-color',
palette,
showBar: false,
};
const fullStateWTrend: Required<MetricVisualizationState> = {
...fullState,
...trendlineProps,
};
const mockFrameApi = createMockFramePublicAPI();
@ -71,149 +97,163 @@ describe('metric visualization', () => {
});
describe('dimension groups configuration', () => {
test('generates configuration', () => {
expect(
visualization.getConfiguration({
state: fullState,
layerId: fullState.layerId,
frame: mockFrameApi,
})
).toMatchSnapshot();
});
test('color-by-value', () => {
expect(
visualization.getConfiguration({
state: fullState,
layerId: fullState.layerId,
frame: mockFrameApi,
}).groups[0].accessors
).toMatchInlineSnapshot(`
Array [
Object {
"columnId": "metric-col-id",
"palette": Array [],
"triggerIcon": "colorBy",
},
]
`);
expect(
visualization.getConfiguration({
state: { ...fullState, palette: undefined, color: undefined },
layerId: fullState.layerId,
frame: mockFrameApi,
}).groups[0].accessors
).toMatchInlineSnapshot(`
Array [
Object {
"color": "#0077cc",
"columnId": "metric-col-id",
"triggerIcon": "color",
},
]
`);
});
test('static coloring', () => {
expect(
visualization.getConfiguration({
state: { ...fullState, palette: undefined },
layerId: fullState.layerId,
frame: mockFrameApi,
}).groups[0].accessors
).toMatchInlineSnapshot(`
Array [
Object {
"color": "static-color",
"columnId": "metric-col-id",
"triggerIcon": "color",
},
]
`);
expect(
visualization.getConfiguration({
state: { ...fullState, color: undefined },
layerId: fullState.layerId,
frame: mockFrameApi,
}).groups[0].accessors
).toMatchInlineSnapshot(`
Array [
Object {
"columnId": "metric-col-id",
"palette": Array [],
"triggerIcon": "colorBy",
},
]
`);
});
test('collapse function', () => {
expect(
visualization.getConfiguration({
state: fullState,
layerId: fullState.layerId,
frame: mockFrameApi,
}).groups[3].accessors
).toMatchInlineSnapshot(`
Array [
Object {
"columnId": "breakdown-col-id",
"triggerIcon": "aggregate",
},
]
`);
expect(
visualization.getConfiguration({
state: { ...fullState, collapseFn: undefined },
layerId: fullState.layerId,
frame: mockFrameApi,
}).groups[3].accessors
).toMatchInlineSnapshot(`
Array [
Object {
"columnId": "breakdown-col-id",
"triggerIcon": undefined,
},
]
`);
});
describe('operation filtering', () => {
const unsupportedDataType = 'string';
const operations: OperationMetadata[] = [
{
isBucketed: true,
dataType: 'number',
},
{
isBucketed: true,
dataType: unsupportedDataType,
},
{
isBucketed: false,
dataType: 'number',
},
{
isBucketed: false,
dataType: unsupportedDataType,
},
];
const testConfig = visualization
.getConfiguration({
state: fullState,
layerId: fullState.layerId,
frame: mockFrameApi,
})
.groups.map(({ groupId, filterOperations }) => [groupId, filterOperations]);
it.each(testConfig)('%s supports correct operations', (_, filterFn) => {
describe('primary layer', () => {
test('generates configuration', () => {
expect(
operations.filter(filterFn as (operation: OperationMetadata) => boolean)
visualization.getConfiguration({
state: fullState,
layerId: fullState.layerId,
frame: mockFrameApi,
})
).toMatchSnapshot();
});
test('color-by-value', () => {
expect(
visualization.getConfiguration({
state: fullState,
layerId: fullState.layerId,
frame: mockFrameApi,
}).groups[0].accessors
).toMatchInlineSnapshot(`
Array [
Object {
"columnId": "metric-col-id",
"palette": Array [],
"triggerIcon": "colorBy",
},
]
`);
expect(
visualization.getConfiguration({
state: { ...fullState, palette: undefined, color: undefined },
layerId: fullState.layerId,
frame: mockFrameApi,
}).groups[0].accessors
).toMatchInlineSnapshot(`
Array [
Object {
"color": "#f5f7fa",
"columnId": "metric-col-id",
"triggerIcon": "color",
},
]
`);
});
test('static coloring', () => {
expect(
visualization.getConfiguration({
state: { ...fullState, palette: undefined },
layerId: fullState.layerId,
frame: mockFrameApi,
}).groups[0].accessors
).toMatchInlineSnapshot(`
Array [
Object {
"color": "static-color",
"columnId": "metric-col-id",
"triggerIcon": "color",
},
]
`);
expect(
visualization.getConfiguration({
state: { ...fullState, color: undefined },
layerId: fullState.layerId,
frame: mockFrameApi,
}).groups[0].accessors
).toMatchInlineSnapshot(`
Array [
Object {
"columnId": "metric-col-id",
"palette": Array [],
"triggerIcon": "colorBy",
},
]
`);
});
test('collapse function', () => {
expect(
visualization.getConfiguration({
state: fullState,
layerId: fullState.layerId,
frame: mockFrameApi,
}).groups[3].accessors
).toMatchInlineSnapshot(`
Array [
Object {
"columnId": "breakdown-col-id",
"triggerIcon": "aggregate",
},
]
`);
expect(
visualization.getConfiguration({
state: { ...fullState, collapseFn: undefined },
layerId: fullState.layerId,
frame: mockFrameApi,
}).groups[3].accessors
).toMatchInlineSnapshot(`
Array [
Object {
"columnId": "breakdown-col-id",
"triggerIcon": undefined,
},
]
`);
});
describe('operation filtering', () => {
const unsupportedDataType = 'string';
const operations: OperationMetadata[] = [
{
isBucketed: true,
dataType: 'number',
},
{
isBucketed: true,
dataType: unsupportedDataType,
},
{
isBucketed: false,
dataType: 'number',
},
{
isBucketed: false,
dataType: unsupportedDataType,
},
];
const testConfig = visualization
.getConfiguration({
state: fullState,
layerId: fullState.layerId,
frame: mockFrameApi,
})
.groups.map(({ groupId, filterOperations }) => [groupId, filterOperations]);
it.each(testConfig)('%s supports correct operations', (_, filterFn) => {
expect(
operations.filter(filterFn as (operation: OperationMetadata) => boolean)
).toMatchSnapshot();
});
});
});
describe('trendline layer', () => {
test('generates configuration', () => {
expect(
visualization.getConfiguration({
state: fullStateWTrend,
layerId: fullStateWTrend.trendlineLayerId,
frame: mockFrameApi,
})
).toMatchSnapshot();
});
});
@ -231,6 +271,7 @@ describe('metric visualization', () => {
datasourceLayers = {
first: mockDatasource.publicAPIMock,
second: mockDatasource.publicAPIMock,
};
});
@ -263,9 +304,10 @@ describe('metric visualization', () => {
"color": Array [
"static-color",
],
"max": Array [
"max-metric-col-id",
"inspectorTableId": Array [
"first",
],
"max": Array [],
"maxCols": Array [
5,
],
@ -301,6 +343,7 @@ describe('metric visualization', () => {
"subtitle": Array [
"subtitle",
],
"trendline": Array [],
},
"function": "metricVis",
"type": "function",
@ -324,9 +367,10 @@ describe('metric visualization', () => {
"color": Array [
"static-color",
],
"max": Array [
"max-metric-col-id",
"inspectorTableId": Array [
"first",
],
"max": Array [],
"maxCols": Array [
5,
],
@ -364,6 +408,7 @@ describe('metric visualization', () => {
"subtitle": Array [
"subtitle",
],
"trendline": Array [],
},
"function": "metricVis",
"type": "function",
@ -374,6 +419,165 @@ describe('metric visualization', () => {
`);
});
describe('trendline expression', () => {
const getTrendlineExpression = (state: MetricVisualizationState) => {
const expression = visualization.toExpression(
state,
datasourceLayers,
{},
{
[trendlineProps.trendlineLayerId]: { chain: [] } as unknown as Ast,
}
) as ExpressionAstExpression;
return expression.chain!.find((fn) => fn.function === 'metricVis')!.arguments.trendline[0];
};
it('adds trendline if prerequisites are present', () => {
expect(getTrendlineExpression({ ...fullStateWTrend, collapseFn: undefined }))
.toMatchInlineSnapshot(`
Object {
"chain": Array [
Object {
"arguments": Object {
"breakdownBy": Array [
"trendline-breakdown-col-id",
],
"inspectorTableId": Array [
"second",
],
"metric": Array [
"trendline-metric-col-id",
],
"table": Array [
Object {
"chain": Array [],
},
],
"timeField": Array [
"trendline-time-col-id",
],
},
"function": "metricTrendline",
"type": "function",
},
],
"type": "expression",
}
`);
expect(
getTrendlineExpression({ ...fullStateWTrend, trendlineBreakdownByAccessor: undefined })
).toMatchInlineSnapshot(`
Object {
"chain": Array [
Object {
"arguments": Object {
"breakdownBy": Array [],
"inspectorTableId": Array [
"second",
],
"metric": Array [
"trendline-metric-col-id",
],
"table": Array [
Object {
"chain": Array [
Object {
"arguments": Object {
"by": Array [
"trendline-time-col-id",
],
"fn": Array [
"sum",
],
"metric": Array [
"trendline-metric-col-id",
],
},
"function": "lens_collapse",
"type": "function",
},
],
},
],
"timeField": Array [
"trendline-time-col-id",
],
},
"function": "metricTrendline",
"type": "function",
},
],
"type": "expression",
}
`);
});
it('should apply collapse-by fn', () => {
expect(getTrendlineExpression(fullStateWTrend)).toMatchInlineSnapshot(`
Object {
"chain": Array [
Object {
"arguments": Object {
"breakdownBy": Array [],
"inspectorTableId": Array [
"second",
],
"metric": Array [
"trendline-metric-col-id",
],
"table": Array [
Object {
"chain": Array [
Object {
"arguments": Object {
"by": Array [
"trendline-time-col-id",
],
"fn": Array [
"sum",
],
"metric": Array [
"trendline-metric-col-id",
],
},
"function": "lens_collapse",
"type": "function",
},
],
},
],
"timeField": Array [
"trendline-time-col-id",
],
},
"function": "metricTrendline",
"type": "function",
},
],
"type": "expression",
}
`);
});
it('no trendline if no trendline layer', () => {
expect(
getTrendlineExpression({ ...fullStateWTrend, trendlineLayerId: undefined })
).toBeUndefined();
});
it('no trendline if either metric or timefield are missing', () => {
expect(
getTrendlineExpression({ ...fullStateWTrend, trendlineMetricAccessor: undefined })
).toBeUndefined();
expect(
getTrendlineExpression({ ...fullStateWTrend, trendlineTimeAccessor: undefined })
).toBeUndefined();
});
});
describe('with collapse function', () => {
it('builds breakdown by metric with collapse function', () => {
const ast = visualization.toExpression(
@ -491,6 +695,7 @@ describe('metric visualization', () => {
visualization.toExpression(
{
...fullState,
showBar: true,
color: undefined,
},
datasourceLayers
@ -498,12 +703,42 @@ describe('metric visualization', () => {
).chain[1].arguments.color[0]
).toBe(euiLightVars.euiColorPrimary);
expect(
(
visualization.toExpression(
{
...fullState,
showBar: false,
color: undefined,
},
datasourceLayers
) as ExpressionAstExpression
).chain[1].arguments.color[0]
).toBe(euiLightVars.euiColorLightestShade);
expect(
(
visualization.toExpression(
{
...fullState,
maxAccessor: undefined,
showBar: false,
color: undefined,
},
datasourceLayers
) as ExpressionAstExpression
).chain[1].arguments.color[0]
).toBe(euiLightVars.euiColorLightestShade);
// this case isn't currently relevant because other parts of the code don't allow showBar to be
// set when there isn't a max dimension but this test covers the branch anyhow
expect(
(
visualization.toExpression(
{
...fullState,
maxAccessor: undefined,
showBar: true,
color: undefined,
},
datasourceLayers
@ -523,8 +758,141 @@ describe('metric visualization', () => {
`);
});
test('getLayerIds returns the single layer ID', () => {
it('appends a trendline layer', () => {
const newLayerId = 'new-layer-id';
const chk = visualization.appendLayer!(fullState, newLayerId, 'metricTrendline', '');
expect(chk.trendlineLayerId).toBe(newLayerId);
expect(chk.trendlineLayerType).toBe('metricTrendline');
});
it('removes trendline layer', () => {
const chk = visualization.removeLayer!(fullStateWTrend, fullStateWTrend.trendlineLayerId);
expect(chk.trendlineLayerId).toBeUndefined();
expect(chk.trendlineLayerType).toBeUndefined();
expect(chk.trendlineMetricAccessor).toBeUndefined();
expect(chk.trendlineTimeAccessor).toBeUndefined();
expect(chk.trendlineBreakdownByAccessor).toBeUndefined();
});
test('getLayerIds', () => {
expect(visualization.getLayerIds(fullState)).toEqual([fullState.layerId]);
expect(visualization.getLayerIds(fullStateWTrend)).toEqual([
fullStateWTrend.layerId,
fullStateWTrend.trendlineLayerId,
]);
});
test('getLayersToLinkTo', () => {
expect(
visualization.getLayersToLinkTo!(fullStateWTrend, fullStateWTrend.trendlineLayerId)
).toEqual([fullStateWTrend.layerId]);
expect(visualization.getLayersToLinkTo!(fullStateWTrend, 'foo-id')).toEqual([]);
});
describe('linked dimensions', () => {
it('doesnt report links when no trendline layer', () => {
expect(visualization.getLinkedDimensions!(fullState)).toHaveLength(0);
});
it('links metrics when present on leader layer', () => {
const localState: MetricVisualizationState = {
...fullStateWTrend,
breakdownByAccessor: undefined,
secondaryMetricAccessor: undefined,
};
expect(visualization.getLinkedDimensions!(localState)).toMatchInlineSnapshot(`
Array [
Object {
"from": Object {
"columnId": "metric-col-id",
"groupId": "metric",
"layerId": "first",
},
"to": Object {
"columnId": "trendline-metric-col-id",
"groupId": "trendMetric",
"layerId": "second",
},
},
]
`);
const newColumnId = visualization.getLinkedDimensions!({
...localState,
trendlineMetricAccessor: undefined,
})[0].to.columnId;
expect(newColumnId).toBeUndefined();
});
it('links secondary metrics when present on leader layer', () => {
const localState: MetricVisualizationState = {
...fullStateWTrend,
metricAccessor: undefined,
breakdownByAccessor: undefined,
};
expect(visualization.getLinkedDimensions!(localState)).toMatchInlineSnapshot(`
Array [
Object {
"from": Object {
"columnId": "secondary-metric-col-id",
"groupId": "secondaryMetric",
"layerId": "first",
},
"to": Object {
"columnId": "trendline-secondary-metric-col-id",
"groupId": "trendSecondaryMetric",
"layerId": "second",
},
},
]
`);
const newColumnId = visualization.getLinkedDimensions!({
...localState,
trendlineSecondaryMetricAccessor: undefined,
})[0].to.columnId;
expect(newColumnId).toBeUndefined();
});
it('links breakdowns when present', () => {
const localState: MetricVisualizationState = {
...fullStateWTrend,
metricAccessor: undefined,
secondaryMetricAccessor: undefined,
};
expect(visualization.getLinkedDimensions!(localState)).toMatchInlineSnapshot(`
Array [
Object {
"from": Object {
"columnId": "breakdown-col-id",
"groupId": "breakdownBy",
"layerId": "first",
},
"to": Object {
"columnId": "trendline-breakdown-col-id",
"groupId": "trendBreakdownBy",
"layerId": "second",
},
},
]
`);
const newColumnId = visualization.getLinkedDimensions!({
...localState,
trendlineBreakdownByAccessor: undefined,
})[0].to.columnId;
expect(newColumnId).toBeUndefined();
});
});
it('marks trendline layer for removal on index pattern switch', () => {
expect(visualization.getLayersToRemoveOnIndexPatternChange!(fullStateWTrend)).toEqual([
fullStateWTrend.trendlineLayerId,
]);
expect(visualization.getLayersToRemoveOnIndexPatternChange!(fullState)).toEqual([]);
});
it('gives a description', () => {
@ -540,15 +908,32 @@ describe('metric visualization', () => {
it('works without state', () => {
const supportedLayers = visualization.getSupportedLayers();
expect(supportedLayers[0].initialDimensions).toBeUndefined();
expect(supportedLayers).toMatchInlineSnapshot(`
Array [
Object {
"initialDimensions": undefined,
"label": "Visualization",
"type": "data",
},
]
expect(supportedLayers[0]).toMatchInlineSnapshot(`
Object {
"canAddViaMenu": true,
"disabled": true,
"initialDimensions": undefined,
"label": "Visualization",
"type": "data",
}
`);
expect({ ...supportedLayers[1], initialDimensions: undefined }).toMatchInlineSnapshot(`
Object {
"canAddViaMenu": true,
"disabled": false,
"initialDimensions": undefined,
"label": "Trendline",
"type": "metricTrendline",
}
`);
expect(supportedLayers[1].initialDimensions).toHaveLength(1);
expect(supportedLayers[1].initialDimensions![0]).toMatchObject({
groupId: GROUP_ID.TREND_TIME,
autoTimeField: true,
columnId: expect.any(String),
});
});
it('includes max static value dimension when state provided', () => {
@ -563,52 +948,65 @@ describe('metric visualization', () => {
});
});
it('sets dimensions', () => {
describe('setting dimensions', () => {
const state = {} as MetricVisualizationState;
const columnId = 'col-id';
expect(
visualization.setDimension({
prevState: state,
columnId,
groupId: GROUP_ID.METRIC,
layerId: 'some-id',
frame: mockFrameApi,
})
).toEqual({
metricAccessor: columnId,
const cases: Array<{
groupId: typeof GROUP_ID[keyof typeof GROUP_ID];
accessor: keyof MetricVisualizationState;
}> = [
{ groupId: GROUP_ID.METRIC, accessor: 'metricAccessor' },
{ groupId: GROUP_ID.SECONDARY_METRIC, accessor: 'secondaryMetricAccessor' },
{ groupId: GROUP_ID.MAX, accessor: 'maxAccessor' },
{ groupId: GROUP_ID.BREAKDOWN_BY, accessor: 'breakdownByAccessor' },
{ groupId: GROUP_ID.TREND_METRIC, accessor: 'trendlineMetricAccessor' },
{ groupId: GROUP_ID.TREND_SECONDARY_METRIC, accessor: 'trendlineSecondaryMetricAccessor' },
{ groupId: GROUP_ID.TREND_TIME, accessor: 'trendlineTimeAccessor' },
{ groupId: GROUP_ID.TREND_BREAKDOWN_BY, accessor: 'trendlineBreakdownByAccessor' },
];
it.each(cases)('sets %s', ({ groupId, accessor }) => {
expect(
visualization.setDimension({
prevState: state,
columnId,
groupId,
layerId: 'some-id',
frame: mockFrameApi,
})
).toEqual(
expect.objectContaining({
[accessor]: columnId,
})
);
});
expect(
visualization.setDimension({
prevState: state,
columnId,
groupId: GROUP_ID.SECONDARY_METRIC,
layerId: 'some-id',
frame: mockFrameApi,
})
).toEqual({
secondaryMetricAccessor: columnId,
it('shows the progress bar when maximum dimension set', () => {
expect(
visualization.setDimension({
prevState: state,
columnId,
groupId: GROUP_ID.MAX,
layerId: 'some-id',
frame: mockFrameApi,
})
).toEqual({
maxAccessor: columnId,
showBar: true,
});
});
expect(
visualization.setDimension({
prevState: state,
columnId,
groupId: GROUP_ID.MAX,
layerId: 'some-id',
frame: mockFrameApi,
})
).toEqual({
maxAccessor: columnId,
});
expect(
visualization.setDimension({
prevState: state,
columnId,
groupId: GROUP_ID.BREAKDOWN_BY,
layerId: 'some-id',
frame: mockFrameApi,
})
).toEqual({
breakdownByAccessor: columnId,
it('does NOT show the progress bar when maximum dimension set when trendline enabled', () => {
expect(
visualization.setDimension({
prevState: { ...state, ...trendlineProps },
columnId,
groupId: GROUP_ID.MAX,
layerId: 'some-id',
frame: mockFrameApi,
})
).not.toHaveProperty('showBar');
});
});
@ -619,7 +1017,7 @@ describe('metric visualization', () => {
layerId: 'some-id',
columnId: '',
frame: mockFrameApi,
prevState: fullState,
prevState: fullStateWTrend,
};
it('removes metric dimension', () => {
@ -660,6 +1058,38 @@ describe('metric visualization', () => {
expect(removed).not.toHaveProperty('collapseFn');
expect(removed).not.toHaveProperty('maxCols');
});
it('removes trend time dimension', () => {
const removed = visualization.removeDimension({
...removeDimensionParam,
columnId: fullStateWTrend.trendlineTimeAccessor,
});
expect(removed).not.toHaveProperty('trendlineTimeAccessor');
});
it('removes trend metric dimension', () => {
const removed = visualization.removeDimension({
...removeDimensionParam,
columnId: fullStateWTrend.trendlineMetricAccessor,
});
expect(removed).not.toHaveProperty('trendlineMetricAccessor');
});
it('removes trend secondary metric dimension', () => {
const removed = visualization.removeDimension({
...removeDimensionParam,
columnId: fullStateWTrend.trendlineSecondaryMetricAccessor,
});
expect(removed).not.toHaveProperty('trendlineSecondaryMetricAccessor');
});
it('removes trend breakdown-by dimension', () => {
const removed = visualization.removeDimension({
...removeDimensionParam,
columnId: fullStateWTrend.trendlineBreakdownByAccessor,
});
expect(removed).not.toHaveProperty('trendlineBreakdownByAccessor');
});
});
it('implements custom display options', () => {

View file

@ -9,23 +9,24 @@ import React from 'react';
import { i18n } from '@kbn/i18n';
import { I18nProvider } from '@kbn/i18n-react';
import { render } from 'react-dom';
import { Ast, AstFunction } from '@kbn/interpreter';
import { PaletteOutput, PaletteRegistry, CUSTOM_PALETTE, CustomPaletteParams } from '@kbn/coloring';
import { PaletteOutput, PaletteRegistry, CustomPaletteParams } from '@kbn/coloring';
import { ThemeServiceStart } from '@kbn/core/public';
import { VIS_EVENT_TO_TRIGGER } from '@kbn/visualizations-plugin/public';
import { LayoutDirection } from '@elastic/charts';
import { euiLightVars, euiThemeVars } from '@kbn/ui-theme';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { IconChartMetric } from '@kbn/chart-icons';
import { LayerTypes } from '@kbn/expression-xy-plugin/public';
import { CollapseFunction } from '../../../common/expressions';
import type { LayerType } from '../../../common';
import { layerTypes } from '../../../common/layer_types';
import type { FormBasedPersistedState } from '../../datasources/form_based/types';
import { getSuggestions } from './suggestions';
import {
Visualization,
OperationMetadata,
DatasourceLayers,
AccessorConfig,
VisualizationConfigProps,
VisualizationDimensionGroupConfig,
Suggestion,
} from '../../types';
import { GROUP_ID, LENS_METRIC_ID } from './constants';
@ -33,11 +34,17 @@ import { DimensionEditor } from './dimension_editor';
import { Toolbar } from './toolbar';
import { generateId } from '../../id_generator';
import { FormatSelectorOptions } from '../../datasources/form_based/dimension_panel/format_selector';
import { toExpression } from './to_expression';
export const DEFAULT_MAX_COLUMNS = 3;
export const getDefaultColor = (hasMax: boolean) =>
hasMax ? euiLightVars.euiColorPrimary : euiThemeVars.euiColorLightestShade;
export const showingBar = (
state: MetricVisualizationState
): state is MetricVisualizationState & { showBar: true; maxAccessor: string } =>
Boolean(state.showBar && state.maxAccessor);
export const getDefaultColor = (state: MetricVisualizationState) =>
showingBar(state) ? euiLightVars.euiColorPrimary : euiThemeVars.euiColorLightestShade;
export interface MetricVisualizationState {
layerId: string;
@ -48,114 +55,25 @@ export interface MetricVisualizationState {
breakdownByAccessor?: string;
// the dimensions can optionally be single numbers
// computed by collapsing all rows
collapseFn?: string;
collapseFn?: CollapseFunction;
subtitle?: string;
secondaryPrefix?: string;
progressDirection?: LayoutDirection;
showBar?: boolean;
color?: string;
palette?: PaletteOutput<CustomPaletteParams>;
maxCols?: number;
trendlineLayerId?: string;
trendlineLayerType?: LayerType;
trendlineTimeAccessor?: string;
trendlineMetricAccessor?: string;
trendlineSecondaryMetricAccessor?: string;
trendlineBreakdownByAccessor?: string;
}
export const supportedDataTypes = new Set(['number']);
// TODO - deduplicate with gauges?
function computePaletteParams(params: CustomPaletteParams) {
return {
...params,
// rewrite colors and stops as two distinct arguments
colors: (params?.stops || []).map(({ color }) => color),
stops: params?.name === 'custom' ? (params?.stops || []).map(({ stop }) => stop) : [],
reverse: false, // managed at UI level
};
}
const toExpression = (
paletteService: PaletteRegistry,
state: MetricVisualizationState,
datasourceLayers: DatasourceLayers,
datasourceExpressionsByLayers: Record<string, Ast> | undefined = {}
): Ast | null => {
if (!state.metricAccessor) {
return null;
}
const datasource = datasourceLayers[state.layerId];
const datasourceExpression = datasourceExpressionsByLayers[state.layerId];
const maxPossibleTiles =
// if there's a collapse function, no need to calculate since we're dealing with a single tile
state.breakdownByAccessor && !state.collapseFn
? datasource?.getMaxPossibleNumValues(state.breakdownByAccessor)
: null;
const getCollapseFnArguments = () => {
const metric = [state.metricAccessor, state.secondaryMetricAccessor, state.maxAccessor].filter(
Boolean
);
const fn = metric.map((accessor) => {
if (accessor !== state.maxAccessor) {
return state.collapseFn;
} else {
const isMaxStatic = Boolean(
datasource?.getOperationForColumnId(state.maxAccessor!)?.isStaticValue
);
// we do this because the user expects the static value they set to be the same
// even if they define a collapse on the breakdown by
return isMaxStatic ? 'max' : state.collapseFn;
}
});
return {
by: [],
metric,
fn,
};
};
return {
type: 'expression',
chain: [
...(datasourceExpression?.chain ?? []),
...(state.collapseFn
? [
{
type: 'function',
function: 'lens_collapse',
arguments: getCollapseFnArguments(),
} as AstFunction,
]
: []),
{
type: 'function',
function: 'metricVis', // TODO import from plugin
arguments: {
metric: state.metricAccessor ? [state.metricAccessor] : [],
secondaryMetric: state.secondaryMetricAccessor ? [state.secondaryMetricAccessor] : [],
secondaryPrefix:
typeof state.secondaryPrefix !== 'undefined' ? [state.secondaryPrefix] : [],
max: state.maxAccessor ? [state.maxAccessor] : [],
breakdownBy:
state.breakdownByAccessor && !state.collapseFn ? [state.breakdownByAccessor] : [],
subtitle: state.subtitle ? [state.subtitle] : [],
progressDirection: state.progressDirection ? [state.progressDirection] : [],
color: [state.color || getDefaultColor(!!state.maxAccessor)],
palette: state.palette?.params
? [
paletteService
.get(CUSTOM_PALETTE)
.toExpression(computePaletteParams(state.palette.params as CustomPaletteParams)),
]
: [],
maxCols: [state.maxCols ?? DEFAULT_MAX_COLUMNS],
minTiles: maxPossibleTiles ? [maxPossibleTiles] : [],
},
},
],
};
};
export const metricLabel = i18n.translate('xpack.lens.metric.label', {
defaultMessage: 'Metric',
});
@ -163,6 +81,225 @@ const metricGroupLabel = i18n.translate('xpack.lens.metric.groupLabel', {
defaultMessage: 'Goal and single value',
});
const getMetricLayerConfiguration = (
props: VisualizationConfigProps<MetricVisualizationState>
): {
groups: VisualizationDimensionGroupConfig[];
} => {
const isSupportedMetric = (op: OperationMetadata) =>
!op.isBucketed && supportedDataTypes.has(op.dataType);
const isSupportedDynamicMetric = (op: OperationMetadata) =>
!op.isBucketed && supportedDataTypes.has(op.dataType) && !op.isStaticValue;
const getPrimaryAccessorDisplayConfig = (): Partial<AccessorConfig> => {
const stops = props.state.palette?.params?.stops || [];
const hasStaticColoring = !!props.state.color;
const hasDynamicColoring = !!props.state.palette;
return hasDynamicColoring
? {
triggerIcon: 'colorBy',
palette: stops.map(({ color }) => color),
}
: hasStaticColoring
? {
triggerIcon: 'color',
color: props.state.color,
}
: {
triggerIcon: 'color',
color: getDefaultColor(props.state),
};
};
const isBucketed = (op: OperationMetadata) => op.isBucketed;
const formatterOptions: FormatSelectorOptions = {
disableExtraOptions: true,
};
return {
groups: [
{
groupId: GROUP_ID.METRIC,
dataTestSubj: 'lnsMetric_primaryMetricDimensionPanel',
groupLabel: i18n.translate('xpack.lens.primaryMetric.label', {
defaultMessage: 'Primary metric',
}),
paramEditorCustomProps: {
headingLabel: i18n.translate('xpack.lens.primaryMetric.headingLabel', {
defaultMessage: 'Value',
}),
},
accessors: props.state.metricAccessor
? [
{
columnId: props.state.metricAccessor,
...getPrimaryAccessorDisplayConfig(),
},
]
: [],
supportsMoreColumns: !props.state.metricAccessor,
filterOperations: isSupportedDynamicMetric,
enableDimensionEditor: true,
enableFormatSelector: true,
formatSelectorOptions: formatterOptions,
requiredMinDimensionCount: 1,
},
{
groupId: GROUP_ID.SECONDARY_METRIC,
dataTestSubj: 'lnsMetric_secondaryMetricDimensionPanel',
groupLabel: i18n.translate('xpack.lens.metric.secondaryMetric', {
defaultMessage: 'Secondary metric',
}),
paramEditorCustomProps: {
headingLabel: i18n.translate('xpack.lens.primaryMetric.headingLabel', {
defaultMessage: 'Value',
}),
},
accessors: props.state.secondaryMetricAccessor
? [
{
columnId: props.state.secondaryMetricAccessor,
},
]
: [],
supportsMoreColumns: !props.state.secondaryMetricAccessor,
filterOperations: isSupportedDynamicMetric,
enableDimensionEditor: true,
enableFormatSelector: true,
formatSelectorOptions: formatterOptions,
},
{
groupId: GROUP_ID.MAX,
dataTestSubj: 'lnsMetric_maxDimensionPanel',
groupLabel: i18n.translate('xpack.lens.metric.max', { defaultMessage: 'Maximum value' }),
paramEditorCustomProps: {
headingLabel: i18n.translate('xpack.lens.primaryMetric.headingLabel', {
defaultMessage: 'Value',
}),
},
accessors: props.state.maxAccessor
? [
{
columnId: props.state.maxAccessor,
},
]
: [],
supportsMoreColumns: !props.state.maxAccessor,
filterOperations: isSupportedMetric,
enableDimensionEditor: true,
enableFormatSelector: false,
formatSelectorOptions: formatterOptions,
supportStaticValue: true,
prioritizedOperation: 'max',
groupTooltip: i18n.translate('xpack.lens.metric.maxTooltip', {
defaultMessage: 'If the maximum value is specified, the minimum value is fixed at zero.',
}),
},
{
groupId: GROUP_ID.BREAKDOWN_BY,
dataTestSubj: 'lnsMetric_breakdownByDimensionPanel',
groupLabel: i18n.translate('xpack.lens.metric.breakdownBy', {
defaultMessage: 'Break down by',
}),
accessors: props.state.breakdownByAccessor
? [
{
columnId: props.state.breakdownByAccessor,
triggerIcon: props.state.collapseFn ? ('aggregate' as const) : undefined,
},
]
: [],
supportsMoreColumns: !props.state.breakdownByAccessor,
filterOperations: isBucketed,
enableDimensionEditor: true,
enableFormatSelector: true,
formatSelectorOptions: formatterOptions,
},
],
};
};
const getTrendlineLayerConfiguration = (
props: VisualizationConfigProps<MetricVisualizationState>
): {
hidden: boolean;
groups: VisualizationDimensionGroupConfig[];
} => {
return {
hidden: true,
groups: [
{
groupId: GROUP_ID.TREND_METRIC,
groupLabel: i18n.translate('xpack.lens.primaryMetric.label', {
defaultMessage: 'Primary metric',
}),
accessors: props.state.trendlineMetricAccessor
? [
{
columnId: props.state.trendlineMetricAccessor,
},
]
: [],
supportsMoreColumns: !props.state.trendlineMetricAccessor,
filterOperations: () => false,
hideGrouping: true,
nestingOrder: 3,
},
{
groupId: GROUP_ID.TREND_SECONDARY_METRIC,
groupLabel: i18n.translate('xpack.lens.metric.secondaryMetric', {
defaultMessage: 'Secondary metric',
}),
accessors: props.state.trendlineSecondaryMetricAccessor
? [
{
columnId: props.state.trendlineSecondaryMetricAccessor,
},
]
: [],
supportsMoreColumns: !props.state.trendlineSecondaryMetricAccessor,
filterOperations: () => false,
hideGrouping: true,
nestingOrder: 2,
},
{
groupId: GROUP_ID.TREND_TIME,
groupLabel: i18n.translate('xpack.lens.metric.timeField', { defaultMessage: 'Time field' }),
accessors: props.state.trendlineTimeAccessor
? [
{
columnId: props.state.trendlineTimeAccessor,
},
]
: [],
supportsMoreColumns: !props.state.trendlineTimeAccessor,
filterOperations: () => false,
hideGrouping: true,
nestingOrder: 1,
},
{
groupId: GROUP_ID.TREND_BREAKDOWN_BY,
groupLabel: i18n.translate('xpack.lens.metric.breakdownBy', {
defaultMessage: 'Break down by',
}),
accessors: props.state.trendlineBreakdownByAccessor
? [
{
columnId: props.state.trendlineBreakdownByAccessor,
},
]
: [],
supportsMoreColumns: !props.state.trendlineBreakdownByAccessor,
filterOperations: () => false,
hideGrouping: true,
nestingOrder: 0,
},
],
};
};
const removeMetricDimension = (state: MetricVisualizationState) => {
delete state.metricAccessor;
delete state.palette;
@ -177,6 +314,7 @@ const removeSecondaryMetricDimension = (state: MetricVisualizationState) => {
const removeMaxDimension = (state: MetricVisualizationState) => {
delete state.maxAccessor;
delete state.progressDirection;
delete state.showBar;
};
const removeBreakdownByDimension = (state: MetricVisualizationState) => {
@ -222,7 +360,7 @@ export const getMetricVisualization = ({
},
getLayerIds(state) {
return [state.layerId];
return state.trendlineLayerId ? [state.layerId, state.trendlineLayerId] : [state.layerId];
},
getDescription() {
@ -238,7 +376,7 @@ export const getMetricVisualization = ({
return (
state ?? {
layerId: addNewLayer(),
layerType: LayerTypes.DATA,
layerType: layerTypes.DATA,
palette: mainPalette,
}
);
@ -246,153 +384,25 @@ export const getMetricVisualization = ({
triggers: [VIS_EVENT_TO_TRIGGER.filter],
getConfiguration(props) {
const isSupportedMetric = (op: OperationMetadata) =>
!op.isBucketed && supportedDataTypes.has(op.dataType);
return props.layerId === props.state.layerId
? getMetricLayerConfiguration(props)
: getTrendlineLayerConfiguration(props);
},
const isSupportedDynamicMetric = (op: OperationMetadata) =>
!op.isBucketed && supportedDataTypes.has(op.dataType) && !op.isStaticValue;
getLayerType(layerId, state) {
if (state?.layerId === layerId) {
return state.layerType;
}
const getPrimaryAccessorDisplayConfig = (): Partial<AccessorConfig> => {
const stops = props.state.palette?.params?.stops || [];
const hasStaticColoring = !!props.state.color;
const hasDynamicColoring = !!props.state.palette;
return hasDynamicColoring
? {
triggerIcon: 'colorBy',
palette: stops.map(({ color }) => color),
}
: hasStaticColoring
? {
triggerIcon: 'color',
color: props.state.color,
}
: {
triggerIcon: 'color',
color: getDefaultColor(!!props.state.maxAccessor),
};
};
const isBucketed = (op: OperationMetadata) => op.isBucketed;
const formatterOptions: FormatSelectorOptions = {
disableExtraOptions: true,
};
return {
groups: [
{
groupId: GROUP_ID.METRIC,
dataTestSubj: 'lnsMetric_primaryMetricDimensionPanel',
groupLabel: i18n.translate('xpack.lens.primaryMetric.label', {
defaultMessage: 'Primary metric',
}),
paramEditorCustomProps: {
headingLabel: i18n.translate('xpack.lens.primaryMetric.headingLabel', {
defaultMessage: 'Value',
}),
},
layerId: props.state.layerId,
accessors: props.state.metricAccessor
? [
{
columnId: props.state.metricAccessor,
...getPrimaryAccessorDisplayConfig(),
},
]
: [],
supportsMoreColumns: !props.state.metricAccessor,
filterOperations: isSupportedDynamicMetric,
enableDimensionEditor: true,
enableFormatSelector: true,
formatSelectorOptions: formatterOptions,
requiredMinDimensionCount: 1,
},
{
groupId: GROUP_ID.SECONDARY_METRIC,
dataTestSubj: 'lnsMetric_secondaryMetricDimensionPanel',
groupLabel: i18n.translate('xpack.lens.metric.secondaryMetric', {
defaultMessage: 'Secondary metric',
}),
paramEditorCustomProps: {
headingLabel: i18n.translate('xpack.lens.primaryMetric.headingLabel', {
defaultMessage: 'Value',
}),
},
layerId: props.state.layerId,
accessors: props.state.secondaryMetricAccessor
? [
{
columnId: props.state.secondaryMetricAccessor,
},
]
: [],
supportsMoreColumns: !props.state.secondaryMetricAccessor,
filterOperations: isSupportedDynamicMetric,
enableDimensionEditor: true,
enableFormatSelector: true,
formatSelectorOptions: formatterOptions,
requiredMinDimensionCount: 0,
},
{
groupId: GROUP_ID.MAX,
dataTestSubj: 'lnsMetric_maxDimensionPanel',
groupLabel: i18n.translate('xpack.lens.metric.max', { defaultMessage: 'Maximum value' }),
paramEditorCustomProps: {
headingLabel: i18n.translate('xpack.lens.primaryMetric.headingLabel', {
defaultMessage: 'Value',
}),
},
layerId: props.state.layerId,
accessors: props.state.maxAccessor
? [
{
columnId: props.state.maxAccessor,
},
]
: [],
supportsMoreColumns: !props.state.maxAccessor,
filterOperations: isSupportedMetric,
enableDimensionEditor: true,
enableFormatSelector: false,
formatSelectorOptions: formatterOptions,
supportStaticValue: true,
prioritizedOperation: 'max',
requiredMinDimensionCount: 0,
groupTooltip: i18n.translate('xpack.lens.metric.maxTooltip', {
defaultMessage:
'If the maximum value is specified, the minimum value is fixed at zero.',
}),
},
{
groupId: GROUP_ID.BREAKDOWN_BY,
dataTestSubj: 'lnsMetric_breakdownByDimensionPanel',
groupLabel: i18n.translate('xpack.lens.metric.breakdownBy', {
defaultMessage: 'Break down by',
}),
layerId: props.state.layerId,
accessors: props.state.breakdownByAccessor
? [
{
columnId: props.state.breakdownByAccessor,
triggerIcon: props.state.collapseFn ? ('aggregate' as const) : undefined,
},
]
: [],
supportsMoreColumns: !props.state.breakdownByAccessor,
filterOperations: isBucketed,
enableDimensionEditor: true,
enableFormatSelector: true,
formatSelectorOptions: formatterOptions,
requiredMinDimensionCount: 0,
},
],
};
if (state?.trendlineLayerId === layerId) {
return state.trendlineLayerType;
}
},
getSupportedLayers(state) {
return [
{
type: LayerTypes.DATA,
type: layerTypes.DATA,
label: i18n.translate('xpack.lens.metric.addLayer', {
defaultMessage: 'Visualization',
}),
@ -405,14 +415,112 @@ export const getMetricVisualization = ({
},
]
: undefined,
disabled: true,
canAddViaMenu: true,
},
{
type: layerTypes.METRIC_TRENDLINE,
label: i18n.translate('xpack.lens.metric.layerType.trendLine', {
defaultMessage: 'Trendline',
}),
initialDimensions: [
{ groupId: GROUP_ID.TREND_TIME, columnId: generateId(), autoTimeField: true },
],
disabled: Boolean(state?.trendlineLayerId),
canAddViaMenu: true,
},
];
},
getLayerType(layerId, state) {
if (state?.layerId === layerId) {
return state.layerType;
appendLayer(state, layerId, layerType) {
if (layerType !== layerTypes.METRIC_TRENDLINE) {
throw new Error(`Metric vis only supports layers of type ${layerTypes.METRIC_TRENDLINE}!`);
}
return { ...state, trendlineLayerId: layerId, trendlineLayerType: layerType };
},
removeLayer(state) {
const newState: MetricVisualizationState = {
...state,
trendlineLayerId: undefined,
trendlineLayerType: undefined,
trendlineMetricAccessor: undefined,
trendlineTimeAccessor: undefined,
trendlineBreakdownByAccessor: undefined,
};
return newState;
},
getLayersToLinkTo(state, newLayerId: string): string[] {
return newLayerId === state.trendlineLayerId ? [state.layerId] : [];
},
getLinkedDimensions(state) {
if (!state.trendlineLayerId) {
return [];
}
const links: Array<{
from: { columnId: string; groupId: string; layerId: string };
to: {
columnId?: string;
groupId: string;
layerId: string;
};
}> = [];
if (state.metricAccessor) {
links.push({
from: {
columnId: state.metricAccessor,
groupId: GROUP_ID.METRIC,
layerId: state.layerId,
},
to: {
columnId: state.trendlineMetricAccessor,
groupId: GROUP_ID.TREND_METRIC,
layerId: state.trendlineLayerId,
},
});
}
if (state.secondaryMetricAccessor) {
links.push({
from: {
columnId: state.secondaryMetricAccessor,
groupId: GROUP_ID.SECONDARY_METRIC,
layerId: state.layerId,
},
to: {
columnId: state.trendlineSecondaryMetricAccessor,
groupId: GROUP_ID.TREND_SECONDARY_METRIC,
layerId: state.trendlineLayerId,
},
});
}
if (state.breakdownByAccessor) {
links.push({
from: {
columnId: state.breakdownByAccessor,
groupId: GROUP_ID.BREAKDOWN_BY,
layerId: state.layerId,
},
to: {
columnId: state.trendlineBreakdownByAccessor,
groupId: GROUP_ID.TREND_BREAKDOWN_BY,
layerId: state.trendlineLayerId,
},
});
}
return links;
},
getLayersToRemoveOnIndexPatternChange: (state) => {
return state.trendlineLayerId ? [state.trendlineLayerId] : [];
},
toExpression: (state, datasourceLayers, attributes, datasourceExpressionsByLayers) =>
@ -430,10 +538,25 @@ export const getMetricVisualization = ({
break;
case GROUP_ID.MAX:
updated.maxAccessor = columnId;
if (!prevState.trendlineLayerId) {
updated.showBar = true;
}
break;
case GROUP_ID.BREAKDOWN_BY:
updated.breakdownByAccessor = columnId;
break;
case GROUP_ID.TREND_TIME:
updated.trendlineTimeAccessor = columnId;
break;
case GROUP_ID.TREND_METRIC:
updated.trendlineMetricAccessor = columnId;
break;
case GROUP_ID.TREND_SECONDARY_METRIC:
updated.trendlineSecondaryMetricAccessor = columnId;
break;
case GROUP_ID.TREND_BREAKDOWN_BY:
updated.trendlineBreakdownByAccessor = columnId;
break;
}
return updated;
@ -455,6 +578,19 @@ export const getMetricVisualization = ({
removeBreakdownByDimension(updated);
}
if (prevState.trendlineTimeAccessor === columnId) {
delete updated.trendlineTimeAccessor;
}
if (prevState.trendlineMetricAccessor === columnId) {
delete updated.trendlineMetricAccessor;
}
if (prevState.trendlineSecondaryMetricAccessor === columnId) {
delete updated.trendlineSecondaryMetricAccessor;
}
if (prevState.trendlineBreakdownByAccessor === columnId) {
delete updated.trendlineBreakdownByAccessor;
}
return updated;
},

View file

@ -331,7 +331,7 @@ export function DimensionEditor(
)}
<CollapseSetting
value={currentLayer?.collapseFns?.[props.accessor] || ''}
onChange={(collapseFn: string) => {
onChange={(collapseFn) => {
props.setState({
...props.state,
layers: props.state.layers.map((layer) =>

View file

@ -20,6 +20,7 @@ import { FramePublicAPI } from '../../types';
import { themeServiceMock } from '@kbn/core/public/mocks';
import { cloneDeep } from 'lodash';
import { PartitionChartsMeta } from './partition_charts_meta';
import { CollapseFunction } from '../../../common/expressions';
jest.mock('../../id_generator');
@ -76,7 +77,7 @@ describe('pie_visualization', () => {
it("doesn't count collapsed dimensions", () => {
state.layers[0].collapseFns = {
[colIds[0]]: 'some-fn',
[colIds[0]]: 'some-fn' as CollapseFunction,
};
expect(pieVisualization.getErrorMessages(state)).toHaveLength(0);

View file

@ -36,6 +36,7 @@ import {
} from '@kbn/chart-icons';
import { DistributiveOmit } from '@elastic/eui';
import { CollapseFunction } from '../../../common/expressions';
import type { VisualizationType } from '../../types';
import type { ValueLabelConfig } from '../../../common/types';
@ -99,7 +100,7 @@ export interface XYDataLayerConfig {
yConfig?: YConfig[];
splitAccessor?: string;
palette?: PaletteOutput;
collapseFn?: string;
collapseFn?: CollapseFunction;
xScaleType?: XScaleType;
isHistogram?: boolean;
columnToLabel?: string;

View file

@ -292,6 +292,7 @@ describe('xy_visualization', () => {
label: 'date_histogram',
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
};
}
return null;
@ -1685,6 +1686,7 @@ describe('xy_visualization', () => {
label: 'date_histogram',
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
};
}
return null;
@ -1715,6 +1717,7 @@ describe('xy_visualization', () => {
label: 'date_histogram',
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
};
}
return null;
@ -1759,6 +1762,7 @@ describe('xy_visualization', () => {
label: 'histogram',
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
};
}
return null;
@ -1791,6 +1795,7 @@ describe('xy_visualization', () => {
label: 'top values',
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
};
}
return null;
@ -1821,6 +1826,7 @@ describe('xy_visualization', () => {
label: 'top values',
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
};
}
return null;
@ -1930,6 +1936,7 @@ describe('xy_visualization', () => {
label: 'date_histogram',
isStaticValue: false,
hasTimeShift: false,
hasReducedTimeRange: false,
};
}
return null;

View file

@ -156,6 +156,10 @@ export const getXyVisualization = ({
},
appendLayer(state, layerId, layerType, indexPatternId) {
if (layerType === 'metricTrendline') {
return state;
}
const firstUsedSeriesType = getDataLayers(state.layers)?.[0]?.seriesType;
return {
...state,

View file

@ -8,7 +8,7 @@
import { i18n } from '@kbn/i18n';
import { uniq } from 'lodash';
import { IconChartBarHorizontal, IconChartBarStacked, IconChartMixedXy } from '@kbn/chart-icons';
import { LayerTypes } from '@kbn/expression-xy-plugin/public';
import type { LayerType as XYLayerType } from '@kbn/expression-xy-plugin/common';
import { DatasourceLayers, OperationMetadata, VisualizationType } from '../../types';
import {
State,
@ -21,7 +21,7 @@ import {
SeriesType,
} from './types';
import { isHorizontalChart } from './state_helpers';
import type { LayerType } from '../../../common';
import { layerTypes } from '../..';
export function getAxisName(
axis: 'x' | 'y' | 'yLeft' | 'yRight',
@ -121,7 +121,7 @@ export function checkScaleOperation(
}
export const isDataLayer = (layer: XYLayerConfig): layer is XYDataLayerConfig =>
layer.layerType === LayerTypes.DATA || !layer.layerType;
layer.layerType === layerTypes.DATA || !layer.layerType;
export const getDataLayers = (layers: XYLayerConfig[]) =>
(layers || []).filter((layer): layer is XYDataLayerConfig => isDataLayer(layer));
@ -131,31 +131,31 @@ export const getFirstDataLayer = (layers: XYLayerConfig[]) =>
export const isReferenceLayer = (
layer: Pick<XYLayerConfig, 'layerType'>
): layer is XYReferenceLineLayerConfig => layer.layerType === LayerTypes.REFERENCELINE;
): layer is XYReferenceLineLayerConfig => layer.layerType === layerTypes.REFERENCELINE;
export const getReferenceLayers = (layers: Array<Pick<XYLayerConfig, 'layerType'>>) =>
(layers || []).filter((layer): layer is XYReferenceLineLayerConfig => isReferenceLayer(layer));
export const isAnnotationsLayer = (
layer: Pick<XYLayerConfig, 'layerType'>
): layer is XYAnnotationLayerConfig => layer.layerType === LayerTypes.ANNOTATIONS;
): layer is XYAnnotationLayerConfig => layer.layerType === layerTypes.ANNOTATIONS;
export const getAnnotationsLayers = (layers: Array<Pick<XYLayerConfig, 'layerType'>>) =>
(layers || []).filter((layer): layer is XYAnnotationLayerConfig => isAnnotationsLayer(layer));
export interface LayerTypeToLayer {
[LayerTypes.DATA]: (layer: XYDataLayerConfig) => XYDataLayerConfig;
[LayerTypes.REFERENCELINE]: (layer: XYReferenceLineLayerConfig) => XYReferenceLineLayerConfig;
[LayerTypes.ANNOTATIONS]: (layer: XYAnnotationLayerConfig) => XYAnnotationLayerConfig;
[layerTypes.DATA]: (layer: XYDataLayerConfig) => XYDataLayerConfig;
[layerTypes.REFERENCELINE]: (layer: XYReferenceLineLayerConfig) => XYReferenceLineLayerConfig;
[layerTypes.ANNOTATIONS]: (layer: XYAnnotationLayerConfig) => XYAnnotationLayerConfig;
}
export const getLayerTypeOptions = (layer: XYLayerConfig, options: LayerTypeToLayer) => {
if (isDataLayer(layer)) {
return options[LayerTypes.DATA](layer);
return options[layerTypes.DATA](layer);
} else if (isReferenceLayer(layer)) {
return options[LayerTypes.REFERENCELINE](layer);
return options[layerTypes.REFERENCELINE](layer);
}
return options[LayerTypes.ANNOTATIONS](layer);
return options[layerTypes.ANNOTATIONS](layer);
};
export function getVisualizationType(state: State): VisualizationType | 'mixed' {
@ -211,7 +211,7 @@ export const defaultIcon = IconChartBarStacked;
export const defaultSeriesType = 'bar_stacked';
export const supportedDataLayer = {
type: LayerTypes.DATA,
type: layerTypes.DATA,
label: i18n.translate('xpack.lens.xyChart.addDataLayerLabel', {
defaultMessage: 'Visualization',
}),
@ -253,7 +253,7 @@ export function getMessageIdsForDimension(
}
const newLayerFn = {
[LayerTypes.DATA]: ({
[layerTypes.DATA]: ({
layerId,
seriesType,
}: {
@ -261,16 +261,16 @@ const newLayerFn = {
seriesType: SeriesType;
}): XYDataLayerConfig => ({
layerId,
layerType: LayerTypes.DATA,
layerType: layerTypes.DATA,
accessors: [],
seriesType,
}),
[LayerTypes.REFERENCELINE]: ({ layerId }: { layerId: string }): XYReferenceLineLayerConfig => ({
[layerTypes.REFERENCELINE]: ({ layerId }: { layerId: string }): XYReferenceLineLayerConfig => ({
layerId,
layerType: LayerTypes.REFERENCELINE,
layerType: layerTypes.REFERENCELINE,
accessors: [],
}),
[LayerTypes.ANNOTATIONS]: ({
[layerTypes.ANNOTATIONS]: ({
layerId,
indexPatternId,
}: {
@ -278,7 +278,7 @@ const newLayerFn = {
indexPatternId: string;
}): XYAnnotationLayerConfig => ({
layerId,
layerType: LayerTypes.ANNOTATIONS,
layerType: layerTypes.ANNOTATIONS,
annotations: [],
indexPatternId,
ignoreGlobalFilters: true,
@ -287,12 +287,12 @@ const newLayerFn = {
export function newLayerState({
layerId,
layerType = LayerTypes.DATA,
layerType = layerTypes.DATA,
seriesType,
indexPatternId,
}: {
layerId: string;
layerType?: LayerType;
layerType?: XYLayerType;
seriesType: SeriesType;
indexPatternId: string;
}) {
@ -300,7 +300,7 @@ export function newLayerState({
}
export function getLayersByType(state: State, byType?: string) {
return state.layers.filter(({ layerType = LayerTypes.DATA }) =>
return state.layers.filter(({ layerType = layerTypes.DATA }) =>
byType ? layerType === byType : true
);
}

View file

@ -11,6 +11,7 @@ import { createDatatableUtilitiesMock } from '@kbn/data-plugin/common/mocks';
import { LayerTypes } from '@kbn/expression-xy-plugin/public';
import { AnnotationsPanel } from '.';
import { FramePublicAPI } from '../../../../types';
import { DatasourcePublicAPI } from '../../../..';
import { createMockFramePublicAPI } from '../../../../mocks';
import { State } from '../../types';
import { Position } from '@elastic/charts';
@ -88,6 +89,9 @@ describe('AnnotationsPanel', () => {
formatFactory={jest.fn()}
paletteService={chartPluginMock.createPaletteRegistry()}
panelRef={React.createRef()}
addLayer={jest.fn()}
removeLayer={jest.fn()}
datasource={{} as DatasourcePublicAPI}
/>
);
@ -152,6 +156,9 @@ describe('AnnotationsPanel', () => {
formatFactory={jest.fn()}
paletteService={chartPluginMock.createPaletteRegistry()}
panelRef={React.createRef()}
addLayer={jest.fn()}
removeLayer={jest.fn()}
datasource={{} as DatasourcePublicAPI}
/>
);
@ -191,6 +198,9 @@ describe('AnnotationsPanel', () => {
formatFactory={jest.fn()}
paletteService={chartPluginMock.createPaletteRegistry()}
panelRef={React.createRef()}
addLayer={jest.fn()}
removeLayer={jest.fn()}
datasource={{} as DatasourcePublicAPI}
/>
);
@ -293,6 +303,9 @@ describe('AnnotationsPanel', () => {
formatFactory={jest.fn()}
paletteService={chartPluginMock.createPaletteRegistry()}
panelRef={React.createRef()}
addLayer={jest.fn()}
removeLayer={jest.fn()}
datasource={{} as DatasourcePublicAPI}
/>
);
@ -350,6 +363,9 @@ describe('AnnotationsPanel', () => {
formatFactory={jest.fn()}
paletteService={chartPluginMock.createPaletteRegistry()}
panelRef={React.createRef()}
addLayer={jest.fn()}
removeLayer={jest.fn()}
datasource={{} as DatasourcePublicAPI}
/>
);
@ -403,6 +419,9 @@ describe('AnnotationsPanel', () => {
formatFactory={jest.fn()}
paletteService={chartPluginMock.createPaletteRegistry()}
panelRef={React.createRef()}
addLayer={jest.fn()}
removeLayer={jest.fn()}
datasource={{} as DatasourcePublicAPI}
/>
);
@ -476,6 +495,9 @@ describe('AnnotationsPanel', () => {
formatFactory={jest.fn()}
paletteService={chartPluginMock.createPaletteRegistry()}
panelRef={React.createRef()}
addLayer={jest.fn()}
removeLayer={jest.fn()}
datasource={{} as DatasourcePublicAPI}
/>
);
@ -547,6 +569,9 @@ describe('AnnotationsPanel', () => {
formatFactory={jest.fn()}
paletteService={chartPluginMock.createPaletteRegistry()}
panelRef={React.createRef()}
addLayer={jest.fn()}
removeLayer={jest.fn()}
datasource={{} as DatasourcePublicAPI}
/>
);
@ -615,6 +640,9 @@ describe('AnnotationsPanel', () => {
formatFactory={jest.fn()}
paletteService={chartPluginMock.createPaletteRegistry()}
panelRef={React.createRef()}
addLayer={jest.fn()}
removeLayer={jest.fn()}
datasource={{} as DatasourcePublicAPI}
/>
);

View file

@ -273,6 +273,9 @@ describe('XY Config panels', () => {
formatFactory={jest.fn()}
paletteService={chartPluginMock.createPaletteRegistry()}
panelRef={React.createRef()}
addLayer={jest.fn()}
removeLayer={jest.fn()}
datasource={{} as DatasourcePublicAPI}
/>
);
@ -298,6 +301,9 @@ describe('XY Config panels', () => {
formatFactory={jest.fn()}
paletteService={chartPluginMock.createPaletteRegistry()}
panelRef={React.createRef()}
addLayer={jest.fn()}
removeLayer={jest.fn()}
datasource={{} as DatasourcePublicAPI}
/>
);
@ -344,6 +350,9 @@ describe('XY Config panels', () => {
formatFactory={jest.fn()}
paletteService={chartPluginMock.createPaletteRegistry()}
panelRef={React.createRef()}
addLayer={jest.fn()}
removeLayer={jest.fn()}
datasource={{} as DatasourcePublicAPI}
/>
);
@ -387,6 +396,9 @@ describe('XY Config panels', () => {
formatFactory={jest.fn()}
paletteService={chartPluginMock.createPaletteRegistry()}
panelRef={React.createRef()}
addLayer={jest.fn()}
removeLayer={jest.fn()}
datasource={{} as DatasourcePublicAPI}
/>
);
@ -430,6 +442,9 @@ describe('XY Config panels', () => {
formatFactory={jest.fn()}
paletteService={chartPluginMock.createPaletteRegistry()}
panelRef={React.createRef()}
addLayer={jest.fn()}
removeLayer={jest.fn()}
datasource={{} as DatasourcePublicAPI}
/>
);

View file

@ -13,6 +13,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const testSubjects = getService('testSubjects');
const filterBar = getService('filterBar');
const retry = getService('retry');
const inspector = getService('inspector');
const clickMetric = async (title: string) => {
const tiles = await PageObjects.lens.getMetricTiles();
@ -62,6 +63,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
extraText: 'Average of bytes 19.76K',
value: '19.76K',
color: 'rgba(245, 247, 250, 1)',
showingTrendline: false,
showingBar: false,
},
{
@ -70,6 +72,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
extraText: 'Average of bytes 18.99K',
value: '18.99K',
color: 'rgba(245, 247, 250, 1)',
showingTrendline: false,
showingBar: false,
},
{
@ -78,6 +81,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
extraText: 'Average of bytes 17.25K',
value: '17.25K',
color: 'rgba(245, 247, 250, 1)',
showingTrendline: false,
showingBar: false,
},
{
@ -86,6 +90,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
extraText: 'Average of bytes 15.69K',
value: '15.69K',
color: 'rgba(245, 247, 250, 1)',
showingTrendline: false,
showingBar: false,
},
{
@ -94,6 +99,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
extraText: 'Average of bytes 15.61K',
value: '15.61K',
color: 'rgba(245, 247, 250, 1)',
showingTrendline: false,
showingBar: false,
},
{
@ -102,6 +108,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
extraText: 'Average of bytes 5.72K',
value: '5.72K',
color: 'rgba(245, 247, 250, 1)',
showingTrendline: false,
showingBar: false,
},
]);
@ -118,6 +125,44 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.lens.removeDimension('lnsMetric_maxDimensionPanel');
});
it('should enable trendlines', async () => {
await PageObjects.lens.openDimensionEditor(
'lnsMetric_primaryMetricDimensionPanel > lns-dimensionTrigger'
);
await testSubjects.click('lnsMetric_supporting_visualization_trendline');
await PageObjects.lens.waitForVisualization('mtrVis');
expect(
(await PageObjects.lens.getMetricVisualizationData()).some(
(datum) => datum.showingTrendline
)
).to.be(true);
await inspector.open('lnsApp_inspectButton');
expect(await inspector.getNumberOfTables()).to.equal(2);
await inspector.close();
await PageObjects.lens.openDimensionEditor(
'lnsMetric_primaryMetricDimensionPanel > lns-dimensionTrigger'
);
await testSubjects.click('lnsMetric_supporting_visualization_none');
await PageObjects.lens.waitForVisualization('mtrVis');
expect(
(await PageObjects.lens.getMetricVisualizationData()).some(
(datum) => datum.showingTrendline
)
).to.be(false);
await PageObjects.lens.closeDimensionEditor();
});
it('should filter by click', async () => {
expect((await filterBar.getFiltersLabel()).length).to.be(0);
const title = '93.28.27.24';

View file

@ -41,6 +41,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
value: '140.05%',
color: 'rgba(245, 247, 250, 1)',
showingBar: true,
showingTrendline: false,
},
]);
});

View file

@ -50,6 +50,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
value: '14.01K',
color: 'rgba(245, 247, 250, 1)',
showingBar: false,
showingTrendline: false,
},
]);
});
@ -78,6 +79,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
value: '13.1B',
color: 'rgba(245, 247, 250, 1)',
showingBar: false,
showingTrendline: false,
},
]);
});
@ -106,6 +108,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
value: '1.44K',
color: 'rgba(245, 247, 250, 1)',
showingBar: false,
showingTrendline: false,
},
]);
});
@ -159,6 +162,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
value: '13.23B',
color: 'rgba(245, 247, 250, 1)',
showingBar: false,
showingTrendline: false,
},
{
title: 'win 7',
@ -167,6 +171,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
value: '13.19B',
color: 'rgba(245, 247, 250, 1)',
showingBar: false,
showingTrendline: false,
},
{
title: 'win xp',
@ -175,6 +180,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
value: '13.07B',
color: 'rgba(245, 247, 250, 1)',
showingBar: false,
showingTrendline: false,
},
{
title: 'win 8',
@ -183,6 +189,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
value: '13.03B',
color: 'rgba(245, 247, 250, 1)',
showingBar: false,
showingTrendline: false,
},
{
title: 'ios',
@ -191,6 +198,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
value: '13.01B',
color: 'rgba(245, 247, 250, 1)',
showingBar: false,
showingTrendline: false,
},
{
title: undefined,
@ -199,6 +207,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
value: undefined,
color: 'rgba(0, 0, 0, 0)',
showingBar: false,
showingTrendline: false,
},
]);

View file

@ -1189,6 +1189,9 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont
color: await (
await this.getMetricElementIfExists('.echMetric', tile)
)?.getComputedStyle('background-color'),
showingTrendline: Boolean(
await this.getMetricElementIfExists('.echSingleMetricSparkline', tile)
),
};
},