[Lens] Expose helpers to capture meta information from Lens state (#144546)

## Summary

Closes: #131710

In order to add complimentary features to Lens embeddables via actions,
it's important to be able to capture the relevant information from the
state which is currently loaded.

E.g. https://github.com/elastic/kibana/pull/129762 is pulling out the
used field names from the Lens state. While the state interface is
considered a public interface (as it's also used to configure Lens
embeddables), it would be beneficial to provide use case specific
helpers to extract this information to make this logic easier to
maintain.

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Uladzislau Lasitsa 2022-11-10 10:05:41 +02:00 committed by GitHub
parent 1d511a439d
commit 545ebb012d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 597 additions and 4 deletions

View file

@ -15,7 +15,7 @@ import type { DiscoverStart } from '@kbn/discover-plugin/public';
import type { IStorageWrapper } from '@kbn/kibana-utils-plugin/public';
import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import { flatten, isEqual } from 'lodash';
import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import type { DataViewsPublicPluginStart, DataView } from '@kbn/data-views-plugin/public';
import type { IndexPatternFieldEditorStart } from '@kbn/data-view-field-editor-plugin/public';
import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { DataPublicPluginStart, ES_FIELD_TYPES } from '@kbn/data-plugin/public';
@ -37,6 +37,7 @@ import type {
IndexPattern,
IndexPatternRef,
DatasourceLayerSettingsProps,
DataSourceInfo,
} from '../../types';
import {
changeIndexPattern,
@ -105,7 +106,7 @@ export { deleteColumn } from './operations';
export function columnToOperation(
column: GenericIndexPatternColumn,
uniqueLabel?: string,
dataView?: IndexPattern
dataView?: IndexPattern | DataView
): OperationDescriptor {
const { dataType, label, isBucketed, scale, operationType, timeShift, reducedTimeRange } = column;
const fieldTypes =
@ -984,6 +985,31 @@ export function getFormBasedDatasource({
getUsedDataViews: (state) => {
return Object.values(state.layers).map(({ indexPatternId }) => indexPatternId);
},
getDatasourceInfo: (state, references, indexPatterns) => {
const layers = references ? injectReferences(state, references).layers : state.layers;
return Object.entries(layers).reduce<DataSourceInfo[]>((acc, [key, layer]) => {
const dataView = indexPatterns?.find(
(indexPattern) => indexPattern.id === layer.indexPatternId
);
const columns = Object.entries(layer.columns).map(([colId, col]) => {
return {
id: colId,
role: col.isBucketed ? ('split' as const) : ('metric' as const),
operation: columnToOperation(col, undefined, dataView),
};
});
acc.push({
layerId: key,
columns,
dataView,
});
return acc;
}, []);
},
};
return formBasedDatasource;

View file

@ -27,6 +27,7 @@ import {
DataType,
TableChangeType,
DatasourceDimensionTriggerProps,
DataSourceInfo,
} from '../../types';
import { generateId } from '../../id_generator';
import { toExpression } from './to_expression';
@ -706,6 +707,31 @@ export function getTextBasedDatasource({
getDatasourceSuggestionsFromCurrentState: getSuggestionsForState,
getDatasourceSuggestionsForVisualizeCharts: getSuggestionsForState,
isEqual: () => true,
getDatasourceInfo: (state, references, indexPatterns) => {
return Object.entries(state.layers).reduce<DataSourceInfo[]>((acc, [key, layer]) => {
const columns = Object.entries(layer.columns).map(([colId, col]) => {
return {
id: colId,
role: col.meta?.type !== 'number' ? ('split' as const) : ('metric' as const),
operation: {
dataType: col?.meta?.type as DataType,
label: col.fieldName,
isBucketed: Boolean(col?.meta?.type !== 'number'),
hasTimeShift: false,
hasReducedTimeRange: false,
},
};
});
acc.push({
layerId: key,
columns,
dataView: indexPatterns?.find((dataView) => dataView.id === layer.index),
});
return acc;
}, []);
},
};
return TextBasedDatasource;

View file

@ -29,7 +29,7 @@ import { LensAttributeService } from '../lens_attribute_service';
import { OnSaveProps } from '@kbn/saved-objects-plugin/public/save_modal';
import { act } from 'react-dom/test-utils';
import { inspectorPluginMock } from '@kbn/inspector-plugin/public/mocks';
import { Visualization } from '../types';
import { Datasource, Visualization } from '../types';
jest.mock('@kbn/inspector-plugin/public', () => ({
isAvailable: false,
@ -1499,4 +1499,129 @@ describe('embeddable', () => {
expect(expressionRenderer).toHaveBeenCalledTimes(2);
expect(expressionRenderer.mock.calls[1][0]!.padding).toBe(undefined);
});
it('should return chart info', async () => {
expressionRenderer = jest.fn((_) => null);
const visDocument: Document = {
state: {
visualization: {},
datasourceStates: {
form_based: {},
},
query: { query: '', language: 'lucene' },
filters: [],
},
references: [],
title: 'My title',
visualizationType: 'testVis',
};
const mockGetDatasourceInfo = jest.fn().mockReturnValue([
{
layerId: 'test',
columns: [
{
id: '1',
role: 'metric',
},
],
},
]);
const mockGetVisualizationInfo = jest.fn().mockReturnValue({
layers: [
{
layerId: 'test',
dimensions: [
{
id: '1',
},
],
},
],
});
const createEmbeddable = (noPadding?: boolean) => {
return new Embeddable(
{
timefilter: dataPluginMock.createSetupContract().query.timefilter.timefilter,
attributeService: attributeServiceMockFromSavedVis(visDocument),
data: dataMock,
expressionRenderer,
basePath,
dataViews: {} as DataViewsContract,
capabilities: {
canSaveDashboards: true,
canSaveVisualizations: true,
discover: {},
navLinks: {},
},
inspector: inspectorPluginMock.createStartContract(),
getTrigger,
theme: themeServiceMock.createStartContract(),
visualizationMap: {
[visDocument.visualizationType as string]: {
getDisplayOptions: () => ({
noPadding: false,
}),
getVisualizationInfo: mockGetVisualizationInfo,
} as unknown as Visualization,
},
datasourceMap: {
form_based: {
getDatasourceInfo: mockGetDatasourceInfo,
} as unknown as Datasource,
},
injectFilterReferences: jest.fn(mockInjectFilterReferences),
documentToExpression: () =>
Promise.resolve({
ast: {
type: 'expression',
chain: [
{ type: 'function', function: 'my', arguments: {} },
{ type: 'function', function: 'expression', arguments: {} },
],
},
errors: undefined,
}),
uiSettings: { get: () => undefined } as unknown as IUiSettingsClient,
},
{
timeRange: {
from: 'now-15m',
to: 'now',
},
noPadding,
} as LensEmbeddableInput
);
};
const embeddable = createEmbeddable();
await embeddable.initializeSavedVis({ id: '123' } as LensEmbeddableInput);
const chartInfo = embeddable.getChartInfo();
expect(mockGetVisualizationInfo).toHaveBeenCalledTimes(1);
expect(mockGetDatasourceInfo).toHaveBeenCalledTimes(1);
expect(chartInfo).toEqual({
filters: [],
layers: [
{
dataView: undefined,
dimensions: [
{
id: '1',
role: 'metric',
},
],
layerId: 'test',
},
],
query: {
language: 'lucene',
query: '',
},
visualizationType: 'testVis',
});
});
});

View file

@ -19,6 +19,7 @@ import {
TimeRange,
isOfQueryType,
} from '@kbn/es-query';
import type { IconType } from '@elastic/eui/src/components/icon/icon';
import type { PaletteOutput } from '@kbn/coloring';
import {
DataPublicPluginStart,
@ -79,6 +80,7 @@ import {
DatasourceMap,
Datasource,
IndexPatternMap,
OperationDescriptor,
} from '../types';
import { getEditPath, DOC_TYPE } from '../../common';
@ -106,6 +108,28 @@ export interface LensUnwrapResult {
metaInfo?: LensUnwrapMetaInfo;
}
interface ChartInfo {
layers: ChartLayerDescriptor[];
visualizationType: string;
filters: Document['state']['filters'];
query: Document['state']['query'];
}
export interface ChartLayerDescriptor {
dataView?: DataView;
layerId: string;
layerType: string;
chartType?: string;
icon?: IconType;
label?: string;
dimensions: Array<{
name: string;
id: string;
role: 'split' | 'metric';
operation: OperationDescriptor;
}>;
}
interface LensBaseEmbeddableInput extends EmbeddableInput {
filters?: Filter[];
query?: Query;
@ -493,6 +517,7 @@ export class Embeddable
this.errors = this.maybeAddConflictError(errors, metaInfo?.sharingSavedObjectProps);
await this.initializeOutput();
this.isInitialized = true;
}
@ -1080,6 +1105,46 @@ export class Embeddable
};
}
public getChartInfo(): Readonly<ChartInfo | undefined> {
const activeDatasourceId = getActiveDatasourceIdFromDoc(this.savedVis);
if (!activeDatasourceId || !this.savedVis?.visualizationType) {
return undefined;
}
const docDatasourceState = this.savedVis?.state.datasourceStates[activeDatasourceId];
const dataSourceInfo = this.deps.datasourceMap[activeDatasourceId].getDatasourceInfo(
docDatasourceState,
this.savedVis?.references,
this.indexPatterns
);
const chartInfo = this.deps.visualizationMap[
this.savedVis.visualizationType
].getVisualizationInfo?.(this.savedVis?.state.visualization);
const layers = chartInfo?.layers.map((l) => {
const dataSource = dataSourceInfo.find((info) => info.layerId === l.layerId);
const updatedDimensions = l.dimensions.map((d) => {
return {
...d,
...dataSource?.columns.find((c) => c.id === d.id)!,
};
});
return {
...l,
dataView: dataSource?.dataView,
dimensions: updatedDimensions,
};
});
return layers
? {
layers,
visualizationType: this.savedVis.visualizationType,
filters: this.savedVis.state.filters,
query: this.savedVis.state.query,
}
: undefined;
}
private get visDisplayOptions(): VisualizationDisplayOptions | undefined {
if (
!this.savedVis?.visualizationType ||

View file

@ -67,6 +67,7 @@ export function createMockDatasource(id: string): DatasourceMock {
getUsedDataView: jest.fn((state, layer) => 'mockip'),
getUsedDataViews: jest.fn(),
onRefreshIndexPattern: jest.fn(),
getDatasourceInfo: jest.fn(),
};
}

View file

@ -27,7 +27,7 @@ import type {
} from '@kbn/ui-actions-plugin/public';
import type { ClickTriggerEvent, BrushTriggerEvent } from '@kbn/charts-plugin/public';
import type { IndexPatternAggRestrictions } from '@kbn/data-plugin/public';
import type { FieldSpec, DataViewSpec } from '@kbn/data-views-plugin/common';
import type { FieldSpec, DataViewSpec, DataView } from '@kbn/data-views-plugin/common';
import type { FieldFormatParams } from '@kbn/field-formats-plugin/common';
import { SearchResponseWarning } from '@kbn/data-plugin/public/search/types';
import type { EuiButtonIconProps } from '@elastic/eui';
@ -134,6 +134,23 @@ export interface TableSuggestionColumn {
operation: Operation;
}
export interface DataSourceInfo {
layerId: string;
dataView?: DataView;
columns: Array<{ id: string; role: 'split' | 'metric'; operation: OperationDescriptor }>;
}
export interface VisualizationInfo {
layers: Array<{
layerId: string;
layerType: string;
chartType?: string;
icon?: IconType;
label?: string;
dimensions: Array<{ name: string; id: string }>;
}>;
}
/**
* A possible table a datasource can create. This object is passed to the visualization
* which tries to build a meaningful visualization given the shape of the table. If this
@ -478,6 +495,12 @@ export interface Datasource<T = unknown, P = unknown> {
setState: StateSetter<T>,
openLayerSettings?: () => void
) => LayerAction[];
getDatasourceInfo: (
state: T,
references?: SavedObjectReference[],
dataViews?: DataView[]
) => DataSourceInfo[];
}
export interface DatasourceFixAction<T> {
@ -1247,6 +1270,8 @@ export interface Visualization<T = unknown, P = unknown> {
getSuggestionFromConvertToLensContext?: (
props: VisualizationStateFromContextChangeProps
) => Suggestion<T> | undefined;
getVisualizationInfo?: (state: T) => VisualizationInfo;
}
// Use same technique as TriggerContext

View file

@ -613,6 +613,39 @@ export const getDatatableVisualization = ({
};
return suggestion;
},
getVisualizationInfo(state: DatatableVisualizationState) {
return {
layers: [
{
layerId: state.layerId,
layerType: state.layerType,
chartType: 'table',
...this.getDescription(state),
dimensions: state.columns.map((column) => {
let name = i18n.translate('xpack.lens.datatable.metric', {
defaultMessage: 'Metric',
});
if (!column.transposable) {
if (column.isTransposed) {
name = i18n.translate('xpack.lens.datatable.breakdownColumns', {
defaultMessage: 'Split metrics by',
});
} else {
name = i18n.translate('xpack.lens.datatable.breakdownRow', {
defaultMessage: 'Row',
});
}
}
return {
id: column.columnId,
name,
};
}),
},
],
};
},
});
function getDataSourceAndSortedColumns(

View file

@ -556,4 +556,54 @@ export const getGaugeVisualization = ({
};
return suggestion;
},
getVisualizationInfo(state: GaugeVisualizationState) {
const dimensions = [];
if (state.metricAccessor) {
dimensions.push({
id: state.metricAccessor,
name: i18n.translate('xpack.lens.gauge.metricLabel', {
defaultMessage: 'Metric',
}),
});
}
if (state.maxAccessor) {
dimensions.push({
id: state.maxAccessor,
name: i18n.translate('xpack.lens.gauge.maxValueLabel', {
defaultMessage: 'Maximum value',
}),
});
}
if (state.minAccessor) {
dimensions.push({
id: state.minAccessor,
name: i18n.translate('xpack.lens.gauge.minValueLabel', {
defaultMessage: 'Minimum value',
}),
});
}
if (state.goalAccessor) {
dimensions.push({
id: state.goalAccessor,
name: i18n.translate('xpack.lens.gauge.goalValueLabel', {
defaultMessage: 'Goal value',
}),
});
}
return {
layers: [
{
layerId: state.layerId,
layerType: state.layerType,
chartType: state.shape,
...this.getDescription(state),
dimensions,
},
],
};
},
});

View file

@ -507,4 +507,42 @@ export const getHeatmapVisualization = ({
};
return suggestion;
},
getVisualizationInfo(state: HeatmapVisualizationState) {
const dimensions = [];
if (state.xAccessor) {
dimensions.push({
id: state.xAccessor,
name: getAxisName(GROUP_ID.X),
});
}
if (state.yAccessor) {
dimensions.push({
id: state.yAccessor,
name: getAxisName(GROUP_ID.Y),
});
}
if (state.valueAccessor) {
dimensions.push({
id: state.valueAccessor,
name: i18n.translate('xpack.lens.heatmap.cellValueLabel', {
defaultMessage: 'Cell value',
}),
});
}
return {
layers: [
{
layerId: state.layerId,
layerType: state.layerType,
chartType: state.shape,
...this.getDescription(state),
dimensions,
},
],
};
},
});

View file

@ -316,4 +316,28 @@ export const getLegacyMetricVisualization = ({
// Is it possible to break it?
return undefined;
},
getVisualizationInfo(state: LegacyMetricState) {
const dimensions = [];
if (state.accessor) {
dimensions.push({
id: state.accessor,
name: i18n.translate('xpack.lens.metric.label', {
defaultMessage: 'Metric',
}),
});
}
return {
layers: [
{
layerId: state.layerId,
layerType: state.layerType,
chartType: 'metric',
...this.getDescription(state),
dimensions,
},
],
};
},
});

View file

@ -669,4 +669,53 @@ export const getMetricVisualization = ({
};
return suggestion;
},
getVisualizationInfo(state: MetricVisualizationState) {
const dimensions = [];
if (state.metricAccessor) {
dimensions.push({
id: state.metricAccessor,
name: i18n.translate('xpack.lens.primaryMetric.label', {
defaultMessage: 'Primary metric',
}),
});
}
if (state.secondaryMetricAccessor) {
dimensions.push({
id: state.secondaryMetricAccessor,
name: i18n.translate('xpack.lens.metric.secondaryMetric', {
defaultMessage: 'Secondary metric',
}),
});
}
if (state.maxAccessor) {
dimensions.push({
id: state.maxAccessor,
name: i18n.translate('xpack.lens.metric.max', { defaultMessage: 'Maximum value' }),
});
}
if (state.breakdownByAccessor) {
dimensions.push({
id: state.breakdownByAccessor,
name: i18n.translate('xpack.lens.metric.breakdownBy', {
defaultMessage: 'Break down by',
}),
});
}
return {
layers: [
{
layerId: state.layerId,
layerType: state.layerType,
chartType: 'metric',
...this.getDescription(state),
dimensions,
},
],
};
},
});

View file

@ -508,4 +508,62 @@ export const getPieVisualization = ({
]
: [];
},
getVisualizationInfo(state: PieVisualizationState) {
const layer = state.layers[0];
const dimensions = [];
if (layer.metric) {
dimensions.push({
id: layer.metric,
name: i18n.translate('xpack.lens.pie.groupsizeLabel', {
defaultMessage: 'Size by',
}),
});
}
if (state.shape === 'mosaic' && layer.secondaryGroups && layer.secondaryGroups.length) {
layer.secondaryGroups.forEach((accessor) => {
dimensions.push({
name: i18n.translate('xpack.lens.pie.horizontalAxisLabel', {
defaultMessage: 'Horizontal axis',
}),
id: accessor,
});
});
}
if (layer.primaryGroups && layer.primaryGroups.length) {
let name = i18n.translate('xpack.lens.pie.treemapGroupLabel', {
defaultMessage: 'Group by',
});
if (state.shape === 'mosaic') {
name = i18n.translate('xpack.lens.pie.verticalAxisLabel', {
defaultMessage: 'Vertical axis',
});
}
if (state.shape === 'donut' || state.shape === 'pie') {
name = i18n.translate('xpack.lens.pie.sliceGroupLabel', {
defaultMessage: 'Slice by',
});
}
layer.primaryGroups.forEach((accessor) => {
dimensions.push({
name,
id: accessor,
});
});
}
return {
layers: [
{
layerId: layer.layerId,
layerType: layer.layerType,
chartType: state.shape,
...this.getDescription(state),
dimensions,
},
],
};
},
});

View file

@ -11,6 +11,7 @@ import { Position } from '@elastic/charts';
import { FormattedMessage, I18nProvider } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import type { PaletteRegistry } from '@kbn/coloring';
import { IconChartBarReferenceLine, IconChartBarAnnotations } from '@kbn/chart-icons';
import { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import { CoreStart, ThemeServiceStart } from '@kbn/core/public';
import type { EventAnnotationServiceType } from '@kbn/event-annotation-plugin/public';
@ -876,6 +877,78 @@ export const getXyVisualization = ({
};
return suggestion;
},
getVisualizationInfo(state: XYState) {
const isHorizontal = isHorizontalChart(state.layers);
const visualizationLayersInfo = state.layers.map((layer) => {
const dimensions = [];
let chartType: SeriesType | undefined;
let icon;
let label;
if (isDataLayer(layer)) {
chartType = layer.seriesType;
const layerVisType = visualizationTypes.find((visType) => visType.id === chartType);
icon = layerVisType?.icon;
label = layerVisType?.fullLabel || layerVisType?.label;
if (layer.xAccessor) {
dimensions.push({ name: getAxisName('x', { isHorizontal }), id: layer.xAccessor });
}
if (layer.accessors && layer.accessors.length) {
layer.accessors.forEach((accessor) => {
dimensions.push({ name: getAxisName('y', { isHorizontal }), id: accessor });
});
}
if (layer.splitAccessor) {
dimensions.push({
name: i18n.translate('xpack.lens.xyChart.splitSeries', {
defaultMessage: 'Breakdown',
}),
id: layer.splitAccessor,
});
}
}
if (isReferenceLayer(layer) && layer.accessors && layer.accessors.length) {
layer.accessors.forEach((accessor) => {
dimensions.push({
name: i18n.translate('xpack.lens.xyChart.layerReferenceLine', {
defaultMessage: 'Reference line',
}),
id: accessor,
});
});
label = i18n.translate('xpack.lens.xyChart.layerReferenceLineLabel', {
defaultMessage: 'Reference lines',
});
icon = IconChartBarReferenceLine;
}
if (isAnnotationsLayer(layer) && layer.annotations && layer.annotations.length) {
layer.annotations.forEach((annotation) => {
dimensions.push({
name: i18n.translate('xpack.lens.xyChart.layerAnnotation', {
defaultMessage: 'Annotation',
}),
id: annotation.id,
});
});
label = i18n.translate('xpack.lens.xyChart.layerAnnotationsLabel', {
defaultMessage: 'Annotations',
});
icon = IconChartBarAnnotations;
}
return {
layerId: layer.layerId,
layerType: layer.layerType,
chartType,
icon,
label,
dimensions,
};
});
return {
layers: visualizationLayersInfo,
};
},
});
const getMappedAccessors = ({