[Lens] Provide formula helper to simplify integration of Lens instances (#122371)

* [Lens] Provide formula helper to simplify integration of Lens instances

Closes: #103055

* remove generateFormulaColumns from start contract

* upsertFormulaColumn

* add upsertFormulaColumn to start contract

* add integration with embedded_lens_examples

* upsert -> insertOrReplace

* add support of overriding operations

* add docs

* fix TS issues

* fix some comments

* fix PR comments

* fix PR comments

* fix CI

* Update x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula_helper.ts

Co-authored-by: Marco Liberati <dej611@users.noreply.github.com>

* Update x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula_helper.ts

Co-authored-by: Marco Liberati <dej611@users.noreply.github.com>

* remove useEffect

* move baseLayer part into getLensAttributes

* introduce stateHelperApi

* Map -> WeakMap

* remove [params.operations] from params

Co-authored-by: Marco Liberati <dej611@users.noreply.github.com>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Alexey Antonov 2022-01-17 22:42:09 +03:00 committed by GitHub
parent 8c0fbdf502
commit 2c52ac28cb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 728 additions and 396 deletions

View file

@ -17,37 +17,32 @@ import {
EuiPageHeader,
EuiPageHeaderSection,
EuiTitle,
EuiCallOut,
} from '@elastic/eui';
import { IndexPattern } from 'src/plugins/data/public';
import { CoreStart } from 'kibana/public';
import { ViewMode } from '../../../../src/plugins/embeddable/public';
import {
import type { DataView } from 'src/plugins/data_views/public';
import type { CoreStart } from 'kibana/public';
import type { StartDependencies } from './plugin';
import type {
TypedLensByValueInput,
PersistedIndexPatternLayer,
XYState,
LensEmbeddableInput,
FormulaPublicApi,
DateHistogramIndexPatternColumn,
} from '../../../plugins/lens/public';
import { StartDependencies } from './plugin';
import { ViewMode } from '../../../../src/plugins/embeddable/public';
// Generate a Lens state based on some app-specific input parameters.
// `TypedLensByValueInput` can be used for type-safety - it uses the same interfaces as Lens-internal code.
function getLensAttributes(
defaultIndexPattern: IndexPattern,
color: string
color: string,
dataView: DataView,
formula: FormulaPublicApi
): TypedLensByValueInput['attributes'] {
const dataLayer: PersistedIndexPatternLayer = {
columnOrder: ['col1', 'col2'],
const baseLayer: PersistedIndexPatternLayer = {
columnOrder: ['col1'],
columns: {
col2: {
dataType: 'number',
isBucketed: false,
label: 'Count of records',
operationType: 'count',
scale: 'ratio',
sourceField: 'Records',
},
col1: {
dataType: 'date',
isBucketed: true,
@ -55,11 +50,18 @@ function getLensAttributes(
operationType: 'date_histogram',
params: { interval: 'auto' },
scale: 'interval',
sourceField: defaultIndexPattern.timeFieldName!,
sourceField: dataView.timeFieldName!,
} as DateHistogramIndexPatternColumn,
},
};
const dataLayer = formula.insertOrReplaceFormulaColumn(
'col2',
{ formula: 'count()' },
baseLayer,
dataView
);
const xyConfig: XYState = {
axisTitlesVisibilitySettings: { x: true, yLeft: true, yRight: true },
fittingFunction: 'None',
@ -85,12 +87,12 @@ function getLensAttributes(
title: 'Prefilled from example app',
references: [
{
id: defaultIndexPattern.id!,
id: dataView.id!,
name: 'indexpattern-datasource-current-indexpattern',
type: 'index-pattern',
},
{
id: defaultIndexPattern.id!,
id: dataView.id!,
name: 'indexpattern-datasource-layer-layer1',
type: 'index-pattern',
},
@ -99,7 +101,7 @@ function getLensAttributes(
datasourceStates: {
indexpattern: {
layers: {
layer1: dataLayer,
layer1: dataLayer!,
},
},
},
@ -113,19 +115,22 @@ function getLensAttributes(
export const App = (props: {
core: CoreStart;
plugins: StartDependencies;
defaultIndexPattern: IndexPattern | null;
defaultDataView: DataView;
formula: FormulaPublicApi;
}) => {
const [color, setColor] = useState('green');
const [isLoading, setIsLoading] = useState(false);
const [isSaveModalVisible, setIsSaveModalVisible] = useState(false);
const LensComponent = props.plugins.lens.EmbeddableComponent;
const LensSaveModalComponent = props.plugins.lens.SaveModalComponent;
const [time, setTime] = useState({
from: 'now-5d',
to: 'now',
});
const LensComponent = props.plugins.lens.EmbeddableComponent;
const LensSaveModalComponent = props.plugins.lens.SaveModalComponent;
const attributes = getLensAttributes(color, props.defaultDataView, props.formula);
return (
<EuiPage>
<EuiPageBody style={{ maxWidth: 1200, margin: '0 auto' }}>
@ -147,138 +152,122 @@ export const App = (props: {
the series which causes Lens to re-render. The Edit button will take the current
configuration and navigate to a prefilled editor.
</p>
{props.defaultIndexPattern && props.defaultIndexPattern.isTimeBased() ? (
<>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="lns-example-change-color"
isLoading={isLoading}
onClick={() => {
// eslint-disable-next-line no-bitwise
const newColor = '#' + ((Math.random() * 0xffffff) << 0).toString(16);
setColor(newColor);
}}
>
Change color
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
aria-label="Open lens in new tab"
isDisabled={!props.plugins.lens.canUseEditor()}
onClick={() => {
props.plugins.lens.navigateToPrefilledEditor(
{
id: '',
timeRange: time,
attributes: getLensAttributes(props.defaultIndexPattern!, color),
},
{
openInNewTab: true,
}
);
// eslint-disable-next-line no-bitwise
const newColor = '#' + ((Math.random() * 0xffffff) << 0).toString(16);
setColor(newColor);
}}
>
Edit in Lens (new tab)
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
aria-label="Open lens in same tab"
data-test-subj="lns-example-open-editor"
isDisabled={!props.plugins.lens.canUseEditor()}
onClick={() => {
props.plugins.lens.navigateToPrefilledEditor(
{
id: '',
timeRange: time,
attributes: getLensAttributes(props.defaultIndexPattern!, color),
},
{
openInNewTab: false,
}
);
}}
>
Edit in Lens (same tab)
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
aria-label="Save visualization into library or embed directly into any dashboard"
data-test-subj="lns-example-save"
isDisabled={!getLensAttributes(props.defaultIndexPattern, color)}
onClick={() => {
setIsSaveModalVisible(true);
}}
>
Save Visualization
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
aria-label="Change time range"
data-test-subj="lns-example-change-time-range"
isDisabled={!getLensAttributes(props.defaultIndexPattern, color)}
onClick={() => {
setTime({
from: '2015-09-18T06:31:44.000Z',
to: '2015-09-23T18:31:44.000Z',
});
}}
>
Change time range
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
<LensComponent
id=""
withActions
style={{ height: 500 }}
timeRange={time}
attributes={getLensAttributes(props.defaultIndexPattern, color)}
onLoad={(val) => {
setIsLoading(val);
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="lns-example-change-color"
isLoading={isLoading}
onClick={() => {
// eslint-disable-next-line no-bitwise
const newColor = '#' + ((Math.random() * 0xffffff) << 0).toString(16);
setColor(newColor);
}}
onBrushEnd={({ range }) => {
>
Change color
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
aria-label="Open lens in new tab"
isDisabled={!props.plugins.lens.canUseEditor()}
onClick={() => {
props.plugins.lens.navigateToPrefilledEditor(
{
id: '',
timeRange: time,
attributes,
},
{
openInNewTab: true,
}
);
// eslint-disable-next-line no-bitwise
const newColor = '#' + ((Math.random() * 0xffffff) << 0).toString(16);
setColor(newColor);
}}
>
Edit in Lens (new tab)
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
aria-label="Open lens in same tab"
data-test-subj="lns-example-open-editor"
isDisabled={!props.plugins.lens.canUseEditor()}
onClick={() => {
props.plugins.lens.navigateToPrefilledEditor(
{
id: '',
timeRange: time,
attributes,
},
{
openInNewTab: false,
}
);
}}
>
Edit in Lens (same tab)
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
aria-label="Save visualization into library or embed directly into any dashboard"
data-test-subj="lns-example-save"
isDisabled={!attributes}
onClick={() => {
setIsSaveModalVisible(true);
}}
>
Save Visualization
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
aria-label="Change time range"
data-test-subj="lns-example-change-time-range"
isDisabled={!attributes}
onClick={() => {
setTime({
from: new Date(range[0]).toISOString(),
to: new Date(range[1]).toISOString(),
from: '2015-09-18T06:31:44.000Z',
to: '2015-09-23T18:31:44.000Z',
});
}}
onFilter={(_data) => {
// call back event for on filter event
}}
onTableRowClick={(_data) => {
// call back event for on table row click event
}}
viewMode={ViewMode.VIEW}
/>
{isSaveModalVisible && (
<LensSaveModalComponent
initialInput={
getLensAttributes(
props.defaultIndexPattern,
color
) as unknown as LensEmbeddableInput
}
onSave={() => {}}
onClose={() => setIsSaveModalVisible(false)}
/>
)}
</>
) : (
<EuiCallOut
title="Please define a default index pattern to use this demo"
color="danger"
iconType="alert"
>
<p>This demo only works if your default index pattern is set and time based</p>
</EuiCallOut>
>
Change time range
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
<LensComponent
id=""
withActions
style={{ height: 500 }}
timeRange={time}
attributes={attributes}
onLoad={(val) => {
setIsLoading(val);
}}
onBrushEnd={({ range }) => {
setTime({
from: new Date(range[0]).toISOString(),
to: new Date(range[1]).toISOString(),
});
}}
onFilter={(_data) => {
// call back event for on filter event
}}
onTableRowClick={(_data) => {
// call back event for on table row click event
}}
viewMode={ViewMode.VIEW}
/>
{isSaveModalVisible && (
<LensSaveModalComponent
initialInput={attributes as unknown as LensEmbeddableInput}
onSave={() => {}}
onClose={() => setIsSaveModalVisible(false)}
/>
)}
</EuiPageContentBody>
</EuiPageContent>

View file

@ -7,8 +7,10 @@
import * as React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { CoreSetup, AppMountParameters } from 'kibana/public';
import { StartDependencies } from './plugin';
import { EuiCallOut } from '@elastic/eui';
import type { CoreSetup, AppMountParameters } from 'kibana/public';
import type { StartDependencies } from './plugin';
export const mount =
(coreSetup: CoreSetup<StartDependencies>) =>
@ -16,20 +18,27 @@ export const mount =
const [core, plugins] = await coreSetup.getStartServices();
const { App } = await import('./app');
const deps = {
core,
plugins,
};
const defaultIndexPattern = await plugins.data.indexPatterns.getDefault();
const defaultDataView = await plugins.data.indexPatterns.getDefault();
const { formula } = await plugins.lens.stateHelperApi();
const i18nCore = core.i18n;
const reactElement = (
<i18nCore.Context>
<App {...deps} defaultIndexPattern={defaultIndexPattern} />
{defaultDataView && defaultDataView.isTimeBased() ? (
<App core={core} plugins={plugins} defaultDataView={defaultDataView} formula={formula} />
) : (
<EuiCallOut
title="Please define a default index pattern to use this demo"
color="danger"
iconType="alert"
>
<p>This demo only works if your default index pattern is set and time based</p>
</EuiCallOut>
)}
</i18nCore.Context>
);
render(reactElement, element);
return () => unmountComponentAtNode(element);
};

View file

@ -28,6 +28,8 @@ export * from './visualizations/gauge/gauge_visualization';
export * from './visualizations/gauge';
export * from './indexpattern_datasource/indexpattern';
export { createFormulaPublicApi } from './indexpattern_datasource/operations/definitions/formula/formula_public_api';
export * from './indexpattern_datasource';
export * from './editor_frame_service/editor_frame';

View file

@ -55,6 +55,7 @@ export type {
FormulaIndexPatternColumn,
MathIndexPatternColumn,
OverallSumIndexPatternColumn,
FormulaPublicApi,
} from './indexpattern_datasource/types';
export type { LensEmbeddableInput } from './embeddable';

View file

@ -9,6 +9,7 @@ import React, { useCallback, useMemo, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFormRow, EuiComboBox, EuiSpacer, EuiRange } from '@elastic/eui';
import { GenericIndexPatternColumn } from '../indexpattern';
import { isColumnFormatted } from '../operations/definitions/helpers';
const supportedFormats: Record<string, { title: string }> = {
number: {
@ -55,11 +56,9 @@ const RANGE_MAX = 15;
export function FormatSelector(props: FormatSelectorProps) {
const { selectedColumn, onChange } = props;
const currentFormat =
'params' in selectedColumn && selectedColumn.params && 'format' in selectedColumn.params
? selectedColumn.params.format
: undefined;
const currentFormat = isColumnFormatted(selectedColumn)
? selectedColumn.params?.format
: undefined;
const [decimals, setDecimals] = useState(currentFormat?.params?.decimals ?? 2);

View file

@ -21,6 +21,8 @@ import type {
FieldFormatsSetup,
} from '../../../../../src/plugins/field_formats/public';
export type { PersistedIndexPatternLayer, IndexPattern, FormulaPublicApi } from './types';
export interface IndexPatternDatasourceSetupPlugins {
expressions: ExpressionsSetup;
fieldFormats: FieldFormatsSetup;

View file

@ -6,9 +6,11 @@
*/
import { uniq, mapValues, difference } from 'lodash';
import { IStorageWrapper } from 'src/plugins/kibana_utils/public';
import { HttpSetup, SavedObjectReference } from 'kibana/public';
import { InitializationOptions, StateSetter } from '../types';
import type { IStorageWrapper } from 'src/plugins/kibana_utils/public';
import type { DataView } from 'src/plugins/data_views/public';
import type { HttpSetup, SavedObjectReference } from 'kibana/public';
import type { InitializationOptions, StateSetter } from '../types';
import {
IndexPattern,
IndexPatternRef,
@ -17,6 +19,7 @@ import {
IndexPatternField,
IndexPatternLayer,
} from './types';
import { updateLayerIndexPattern, translateToOperationName } from './operations';
import { DateRange, ExistingFields } from '../../common/types';
import { BASE_API_URL } from '../../common';
@ -35,6 +38,72 @@ type SetState = StateSetter<IndexPatternPrivateState>;
type IndexPatternsService = Pick<IndexPatternsContract, 'get' | 'getIdsWithTitle'>;
type ErrorHandler = (err: Error) => void;
export function convertDataViewIntoLensIndexPattern(dataView: DataView): IndexPattern {
const newFields = dataView.fields
.filter(
(field) =>
!indexPatternsUtils.isNestedField(field) && (!!field.aggregatable || !!field.scripted)
)
.map((field): IndexPatternField => {
// Convert the getters on the index pattern service into plain JSON
const base = {
name: field.name,
displayName: field.displayName,
type: field.type,
aggregatable: field.aggregatable,
searchable: field.searchable,
meta: dataView.metaFields.includes(field.name),
esTypes: field.esTypes,
scripted: field.scripted,
runtime: Boolean(field.runtimeField),
};
// Simplifies tests by hiding optional properties instead of undefined
return base.scripted
? {
...base,
lang: field.lang,
script: field.script,
}
: base;
})
.concat(documentField);
const { typeMeta, title, timeFieldName, fieldFormatMap } = dataView;
if (typeMeta?.aggs) {
const aggs = Object.keys(typeMeta.aggs);
newFields.forEach((field, index) => {
const restrictionsObj: IndexPatternField['aggregationRestrictions'] = {};
aggs.forEach((agg) => {
const restriction = typeMeta.aggs && typeMeta.aggs[agg] && typeMeta.aggs[agg][field.name];
if (restriction) {
restrictionsObj[translateToOperationName(agg)] = restriction;
}
});
if (Object.keys(restrictionsObj).length) {
newFields[index] = { ...field, aggregationRestrictions: restrictionsObj };
}
});
}
return {
id: dataView.id!, // id exists for sure because we got index patterns by id
title,
timeFieldName,
fieldFormatMap:
fieldFormatMap &&
Object.fromEntries(
Object.entries(fieldFormatMap).map(([id, format]) => [
id,
'toJSON' in format ? format.toJSON() : format,
])
),
fields: newFields,
getFieldByName: getFieldByNameFactory(newFields),
hasRestrictions: !!typeMeta?.aggs,
};
}
export async function loadIndexPatterns({
indexPatternsService,
patterns,
@ -79,77 +148,10 @@ export async function loadIndexPatterns({
}
const indexPatternsObject = indexPatterns.reduce(
(acc, indexPattern) => {
const newFields = indexPattern.fields
.filter(
(field) =>
!indexPatternsUtils.isNestedField(field) && (!!field.aggregatable || !!field.scripted)
)
.map((field): IndexPatternField => {
// Convert the getters on the index pattern service into plain JSON
const base = {
name: field.name,
displayName: field.displayName,
type: field.type,
aggregatable: field.aggregatable,
searchable: field.searchable,
meta: indexPattern.metaFields.includes(field.name),
esTypes: field.esTypes,
scripted: field.scripted,
runtime: Boolean(field.runtimeField),
};
// Simplifies tests by hiding optional properties instead of undefined
return base.scripted
? {
...base,
lang: field.lang,
script: field.script,
}
: base;
})
.concat(documentField);
const { typeMeta, title, timeFieldName, fieldFormatMap } = indexPattern;
if (typeMeta?.aggs) {
const aggs = Object.keys(typeMeta.aggs);
newFields.forEach((field, index) => {
const restrictionsObj: IndexPatternField['aggregationRestrictions'] = {};
aggs.forEach((agg) => {
const restriction =
typeMeta.aggs && typeMeta.aggs[agg] && typeMeta.aggs[agg][field.name];
if (restriction) {
restrictionsObj[translateToOperationName(agg)] = restriction;
}
});
if (Object.keys(restrictionsObj).length) {
newFields[index] = { ...field, aggregationRestrictions: restrictionsObj };
}
});
}
const currentIndexPattern: IndexPattern = {
id: indexPattern.id!, // id exists for sure because we got index patterns by id
title,
timeFieldName,
fieldFormatMap:
fieldFormatMap &&
Object.fromEntries(
Object.entries(fieldFormatMap).map(([id, format]) => [
id,
'toJSON' in format ? format.toJSON() : format,
])
),
fields: newFields,
getFieldByName: getFieldByNameFactory(newFields),
hasRestrictions: !!typeMeta?.aggs,
};
return {
[currentIndexPattern.id]: currentIndexPattern,
...acc,
};
},
(acc, indexPattern) => ({
[indexPattern.id!]: convertDataViewIntoLensIndexPattern(indexPattern),
...acc,
}),
{ ...cache }
);

View file

@ -20,8 +20,7 @@ export interface BaseIndexPatternColumn extends Operation {
}
// Formatting can optionally be added to any column
// export interface FormattedIndexPatternColumn extends BaseIndexPatternColumn {
export type FormattedIndexPatternColumn = BaseIndexPatternColumn & {
export interface FormattedIndexPatternColumn extends BaseIndexPatternColumn {
params?: {
format?: {
id: string;
@ -30,15 +29,13 @@ export type FormattedIndexPatternColumn = BaseIndexPatternColumn & {
};
};
};
};
}
export interface FieldBasedIndexPatternColumn extends BaseIndexPatternColumn {
sourceField: string;
}
export interface ReferenceBasedIndexPatternColumn
extends BaseIndexPatternColumn,
FormattedIndexPatternColumn {
export interface ReferenceBasedIndexPatternColumn extends FormattedIndexPatternColumn {
references: string[];
}

View file

@ -45,7 +45,7 @@ import { trackUiEvent } from '../../../../../lens_ui_telemetry';
import './formula.scss';
import { FormulaIndexPatternColumn } from '../formula';
import { regenerateLayerFromAst } from '../parse';
import { insertOrReplaceFormulaColumn } from '../parse';
import { filterByVisibleOperation } from '../util';
import { getColumnTimeShiftWarnings, getDateHistogramInterval } from '../../../../time_shift_utils';
@ -151,16 +151,24 @@ export function FormulaEditor({
setIsCloseable(true);
// If the text is not synced, update the column.
if (text !== currentColumn.params.formula) {
updateLayer((prevLayer) => {
return regenerateLayerFromAst(
text || '',
prevLayer,
columnId,
currentColumn,
indexPattern,
operationDefinitionMap
).newLayer;
});
updateLayer(
(prevLayer) =>
insertOrReplaceFormulaColumn(
columnId,
{
...currentColumn,
params: {
...currentColumn.params,
formula: text || '',
},
},
prevLayer,
{
indexPattern,
operations: operationDefinitionMap,
}
).layer
);
}
});
@ -173,15 +181,23 @@ export function FormulaEditor({
monaco.editor.setModelMarkers(editorModel.current, 'LENS', []);
if (currentColumn.params.formula) {
// Only submit if valid
const { newLayer } = regenerateLayerFromAst(
text || '',
layer,
columnId,
currentColumn,
indexPattern,
operationDefinitionMap
updateLayer(
insertOrReplaceFormulaColumn(
columnId,
{
...currentColumn,
params: {
...currentColumn.params,
formula: text || '',
},
},
layer,
{
indexPattern,
operations: operationDefinitionMap,
}
).layer
);
updateLayer(newLayer);
}
return;
@ -215,14 +231,21 @@ export function FormulaEditor({
// If the formula is already broken, show the latest error message in the workspace
if (currentColumn.params.formula !== text) {
updateLayer(
regenerateLayerFromAst(
text || '',
layer,
insertOrReplaceFormulaColumn(
columnId,
currentColumn,
indexPattern,
visibleOperationsMap
).newLayer
{
...currentColumn,
params: {
...currentColumn.params,
formula: text || '',
},
},
layer,
{
indexPattern,
operations: operationDefinitionMap,
}
).layer
);
}
}
@ -270,14 +293,25 @@ export function FormulaEditor({
monaco.editor.setModelMarkers(editorModel.current, 'LENS', []);
// Only submit if valid
const { newLayer, locations } = regenerateLayerFromAst(
text || '',
layer,
const {
layer: newLayer,
meta: { locations },
} = insertOrReplaceFormulaColumn(
columnId,
currentColumn,
indexPattern,
visibleOperationsMap
{
...currentColumn,
params: {
...currentColumn.params,
formula: text || '',
},
},
layer,
{
indexPattern,
operations: operationDefinitionMap,
}
);
updateLayer(newLayer);
const managedColumns = getManagedColumnsFrom(columnId, newLayer.columns);

View file

@ -8,7 +8,7 @@
import { createMockedIndexPattern } from '../../../mocks';
import { formulaOperation, GenericOperationDefinition, GenericIndexPatternColumn } from '../index';
import { FormulaIndexPatternColumn } from './formula';
import { regenerateLayerFromAst } from './parse';
import { insertOrReplaceFormulaColumn } from './parse';
import type { IndexPattern, IndexPatternField, IndexPatternLayer } from '../../../types';
import { tinymathFunctions } from './util';
import { TermsIndexPatternColumn } from '../terms';
@ -424,25 +424,36 @@ describe('formula', () => {
});
});
describe('regenerateLayerFromAst()', () => {
describe('insertOrReplaceFormulaColumn()', () => {
let indexPattern: IndexPattern;
let currentColumn: FormulaIndexPatternColumn;
function testIsBrokenFormula(
formula: string,
columnParams: Partial<Pick<FormulaIndexPatternColumn, 'filter'>> = {}
partialColumn: Partial<Pick<FormulaIndexPatternColumn, 'filter'>> = {}
) {
const mergedColumn = { ...currentColumn, ...columnParams };
const mergedColumn = {
...currentColumn,
...partialColumn,
};
const mergedLayer = { ...layer, columns: { ...layer.columns, col1: mergedColumn } };
expect(
regenerateLayerFromAst(
formula,
mergedLayer,
insertOrReplaceFormulaColumn(
'col1',
mergedColumn,
indexPattern,
operationDefinitionMap
).newLayer
{
...mergedColumn,
params: {
...mergedColumn.params,
formula,
},
},
mergedLayer,
{
indexPattern,
operations: operationDefinitionMap,
}
).layer
).toEqual({
...mergedLayer,
columns: {
@ -475,14 +486,21 @@ describe('formula', () => {
it('should mutate the layer with new columns for valid formula expressions', () => {
expect(
regenerateLayerFromAst(
'average(bytes)',
layer,
insertOrReplaceFormulaColumn(
'col1',
currentColumn,
indexPattern,
operationDefinitionMap
).newLayer
{
...currentColumn,
params: {
...currentColumn.params,
formula: 'average(bytes)',
},
},
layer,
{
indexPattern,
operations: operationDefinitionMap,
}
).layer
).toEqual({
...layer,
columnOrder: ['col1X0', 'col1'],
@ -514,14 +532,21 @@ describe('formula', () => {
it('should create a valid formula expression for numeric literals', () => {
expect(
regenerateLayerFromAst(
'0',
layer,
insertOrReplaceFormulaColumn(
'col1',
currentColumn,
indexPattern,
operationDefinitionMap
).newLayer
{
...currentColumn,
params: {
...currentColumn.params,
formula: '0',
},
},
layer,
{
indexPattern,
operations: operationDefinitionMap,
}
).layer
).toEqual({
...layer,
columnOrder: ['col1X0', 'col1'],
@ -672,14 +697,21 @@ describe('formula', () => {
it('returns the locations of each function', () => {
expect(
regenerateLayerFromAst(
'moving_average(average(bytes), window=7) + count()',
layer,
insertOrReplaceFormulaColumn(
'col1',
currentColumn,
indexPattern,
operationDefinitionMap
).locations
{
...currentColumn,
params: {
...currentColumn.params,
formula: 'moving_average(average(bytes), window=7) + count()',
},
},
layer,
{
indexPattern,
operations: operationDefinitionMap,
}
).meta.locations
).toEqual({
col1X0: { min: 15, max: 29 },
col1X1: { min: 0, max: 41 },
@ -693,14 +725,22 @@ describe('formula', () => {
const mergedLayer = { ...layer, columns: { ...layer.columns, col1: mergedColumn } };
const formula = 'moving_average(average(bytes), window=7) + count()';
const { newLayer } = regenerateLayerFromAst(
formula,
mergedLayer,
const { layer: newLayer } = insertOrReplaceFormulaColumn(
'col1',
mergedColumn,
indexPattern,
operationDefinitionMap
{
...mergedColumn,
params: {
...mergedColumn.params,
formula,
},
},
mergedLayer,
{
indexPattern,
operations: operationDefinitionMap,
}
);
// average and math are not filterable in the mocks
expect(newLayer.columns).toEqual(
expect.objectContaining({
@ -737,14 +777,22 @@ describe('formula', () => {
const mergedLayer = { ...layer, columns: { ...layer.columns, col1: mergedColumn } };
const formula = `moving_average(average(bytes), window=7, kql='${innerFilter}') + count(kql='${innerFilter}')`;
const { newLayer } = regenerateLayerFromAst(
formula,
mergedLayer,
const { layer: newLayer } = insertOrReplaceFormulaColumn(
'col1',
mergedColumn,
indexPattern,
operationDefinitionMap
{
...mergedColumn,
params: {
...mergedColumn.params,
formula,
},
},
mergedLayer,
{
indexPattern,
operations: operationDefinitionMap,
}
);
// average and math are not filterable in the mocks
expect(newLayer.columns).toEqual(
expect.objectContaining({

View file

@ -6,12 +6,12 @@
*/
import { i18n } from '@kbn/i18n';
import type { OperationDefinition } from '../index';
import type { BaseIndexPatternColumn, OperationDefinition } from '../index';
import type { ReferenceBasedIndexPatternColumn } from '../column_types';
import type { IndexPattern } from '../../../types';
import { runASTValidation, tryToParse } from './validation';
import { WrappedFormulaEditor } from './editor';
import { regenerateLayerFromAst } from './parse';
import { insertOrReplaceFormulaColumn } from './parse';
import { generateFormula } from './generate';
import { filterByVisibleOperation } from './util';
import { getManagedColumnsFrom } from '../../layer_helpers';
@ -36,6 +36,12 @@ export interface FormulaIndexPatternColumn extends ReferenceBasedIndexPatternCol
};
}
export function isFormulaIndexPatternColumn(
column: BaseIndexPatternColumn
): column is FormulaIndexPatternColumn {
return 'params' in column && 'formula' in (column as FormulaIndexPatternColumn).params;
}
export const formulaOperation: OperationDefinition<FormulaIndexPatternColumn, 'managedReference'> =
{
type: 'formula',
@ -150,22 +156,11 @@ export const formulaOperation: OperationDefinition<FormulaIndexPatternColumn, 'm
},
createCopy(layer, sourceId, targetId, indexPattern, operationDefinitionMap) {
const currentColumn = layer.columns[sourceId] as FormulaIndexPatternColumn;
const tempLayer = {
...layer,
columns: {
...layer.columns,
[targetId]: { ...currentColumn },
},
};
const { newLayer } = regenerateLayerFromAst(
currentColumn.params.formula ?? '',
tempLayer,
targetId,
currentColumn,
return insertOrReplaceFormulaColumn(targetId, currentColumn, layer, {
indexPattern,
operationDefinitionMap
);
return newLayer;
operations: operationDefinitionMap,
}).layer;
},
paramEditor: WrappedFormulaEditor,

View file

@ -0,0 +1,106 @@
/*
* 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 { convertDataViewIntoLensIndexPattern } from '../../../loader';
import { insertOrReplaceFormulaColumn } from './parse';
import { createFormulaPublicApi, FormulaPublicApi } from './formula_public_api';
import type { DataView } from '../../../../../../../../src/plugins/data_views/public';
import type { DateHistogramIndexPatternColumn, PersistedIndexPatternLayer } from '../../../types';
jest.mock('./parse', () => ({
insertOrReplaceFormulaColumn: jest.fn().mockReturnValue({}),
}));
jest.mock('../../../loader', () => ({
convertDataViewIntoLensIndexPattern: jest.fn((v) => v),
}));
const getBaseLayer = (): PersistedIndexPatternLayer => ({
columnOrder: ['col1'],
columns: {
col1: {
dataType: 'date',
isBucketed: true,
label: '@timestamp',
operationType: 'date_histogram',
params: { interval: 'auto' },
scale: 'interval',
} as DateHistogramIndexPatternColumn,
},
});
describe('createFormulaPublicApi', () => {
let publicApiHelper: FormulaPublicApi;
let dataView: DataView;
beforeEach(() => {
publicApiHelper = createFormulaPublicApi();
dataView = {} as DataView;
jest.clearAllMocks();
});
test('should use cache for caching lens index patterns', () => {
const baseLayer = getBaseLayer();
publicApiHelper.insertOrReplaceFormulaColumn(
'col',
{ formula: 'count()' },
baseLayer,
dataView
);
publicApiHelper.insertOrReplaceFormulaColumn(
'col',
{ formula: 'count()' },
baseLayer,
dataView
);
expect(convertDataViewIntoLensIndexPattern).toHaveBeenCalledTimes(1);
});
test('should execute insertOrReplaceFormulaColumn with valid arguments', () => {
const baseLayer = getBaseLayer();
publicApiHelper.insertOrReplaceFormulaColumn(
'col',
{ formula: 'count()' },
baseLayer,
dataView
);
expect(insertOrReplaceFormulaColumn).toHaveBeenCalledWith(
'col',
{
customLabel: false,
dataType: 'number',
isBucketed: false,
label: 'count()',
operationType: 'formula',
params: { formula: 'count()' },
references: [],
},
{
columnOrder: ['col1'],
columns: {
col1: {
dataType: 'date',
isBucketed: true,
label: '@timestamp',
operationType: 'date_histogram',
params: { interval: 'auto' },
scale: 'interval',
},
},
indexPatternId: undefined,
},
{ indexPattern: {} }
);
});
});

View file

@ -0,0 +1,75 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { IndexPattern, PersistedIndexPatternLayer } from '../../../types';
import type { DataView } from '../../../../../../../../src/plugins/data_views/public';
import { insertOrReplaceFormulaColumn } from './parse';
import { convertDataViewIntoLensIndexPattern } from '../../../loader';
/** @public **/
export interface FormulaPublicApi {
/**
* Method which Lens consumer can import and given a formula string,
* return a parsed result as list of columns to use as Embeddable attributes.
*
* @param id - Formula column id
* @param column.formula - String representation of a formula
* @param [column.label] - Custom formula label
* @param layer - The layer to which the formula columns will be added
* @param dataView - The dataView instance
*
* See `x-pack/examples/embedded_lens_example` for exemplary usage.
*/
insertOrReplaceFormulaColumn: (
id: string,
column: {
formula: string;
label?: string;
},
layer: PersistedIndexPatternLayer,
dataView: DataView
) => PersistedIndexPatternLayer | undefined;
}
/** @public **/
export const createFormulaPublicApi = (): FormulaPublicApi => {
const cache: WeakMap<DataView, IndexPattern> = new WeakMap();
const getCachedLensIndexPattern = (dataView: DataView): IndexPattern => {
const cachedIndexPattern = cache.get(dataView);
if (cachedIndexPattern) {
return cachedIndexPattern;
}
const indexPattern = convertDataViewIntoLensIndexPattern(dataView);
cache.set(dataView, indexPattern);
return indexPattern;
};
return {
insertOrReplaceFormulaColumn: (id, { formula, label }, layer, dataView) => {
const indexPattern = getCachedLensIndexPattern(dataView);
return insertOrReplaceFormulaColumn(
id,
{
label: label ?? formula,
customLabel: Boolean(label),
operationType: 'formula',
dataType: 'number',
references: [],
isBucketed: false,
params: {
formula,
},
},
{ ...layer, indexPatternId: indexPattern.id },
{ indexPattern }
).layer;
},
};
};

View file

@ -7,6 +7,8 @@
export type { FormulaIndexPatternColumn } from './formula';
export { formulaOperation } from './formula';
export { regenerateLayerFromAst } from './parse';
export { insertOrReplaceFormulaColumn } from './parse';
export type { MathIndexPatternColumn } from './math';
export { mathOperation } from './math';

View file

@ -8,10 +8,11 @@
import { i18n } from '@kbn/i18n';
import { isObject } from 'lodash';
import type { TinymathAST, TinymathVariable, TinymathLocation } from '@kbn/tinymath';
import type {
import {
OperationDefinition,
GenericOperationDefinition,
GenericIndexPatternColumn,
operationDefinitionMap,
} from '../index';
import type { IndexPattern, IndexPatternLayer } from '../../../types';
import { mathOperation } from './math';
@ -24,10 +25,11 @@ import {
groupArgsByType,
mergeWithGlobalFilter,
} from './util';
import type { FormulaIndexPatternColumn } from './formula';
import { FormulaIndexPatternColumn, isFormulaIndexPatternColumn } from './formula';
import { getColumnOrder } from '../../layer_helpers';
function getManagedId(mainId: string, index: number) {
/** @internal **/
export function getManagedId(mainId: string, index: number) {
return `${mainId}X${index}`;
}
@ -36,21 +38,15 @@ function parseAndExtract(
layer: IndexPatternLayer,
columnId: string,
indexPattern: IndexPattern,
operationDefinitionMap: Record<string, GenericOperationDefinition>,
operations: Record<string, GenericOperationDefinition>,
label?: string
) {
const { root, error } = tryToParse(text, operationDefinitionMap);
const { root, error } = tryToParse(text, operations);
if (error || root == null) {
return { extracted: [], isValid: false };
}
// before extracting the data run the validation task and throw if invalid
const errors = runASTValidation(
root,
layer,
indexPattern,
operationDefinitionMap,
layer.columns[columnId]
);
const errors = runASTValidation(root, layer, indexPattern, operations, layer.columns[columnId]);
if (errors.length) {
return { extracted: [], isValid: false };
}
@ -59,7 +55,7 @@ function parseAndExtract(
*/
const extracted = extractColumns(
columnId,
operationDefinitionMap,
operations,
root,
layer,
indexPattern,
@ -201,63 +197,116 @@ function extractColumns(
return columns;
}
export function regenerateLayerFromAst(
text: string,
interface ExpandColumnProperties {
indexPattern: IndexPattern;
operations?: Record<string, GenericOperationDefinition>;
}
const getEmptyColumnsWithFormulaMeta = (): {
columns: Record<string, GenericIndexPatternColumn>;
meta: {
locations: Record<string, TinymathLocation>;
};
} => ({
columns: {},
meta: {
locations: {},
},
});
function generateFormulaColumns(
id: string,
column: FormulaIndexPatternColumn,
layer: IndexPatternLayer,
columnId: string,
currentColumn: FormulaIndexPatternColumn,
indexPattern: IndexPattern,
operationDefinitionMap: Record<string, GenericOperationDefinition>
{ indexPattern, operations = operationDefinitionMap }: ExpandColumnProperties
) {
const { columns, meta } = getEmptyColumnsWithFormulaMeta();
const formula = column.params.formula || '';
const { extracted, isValid } = parseAndExtract(
text,
formula,
layer,
columnId,
id,
indexPattern,
filterByVisibleOperation(operationDefinitionMap),
currentColumn.customLabel ? currentColumn.label : undefined
filterByVisibleOperation(operations),
column.customLabel ? column.label : undefined
);
const columns = { ...layer.columns };
extracted.forEach(({ column: extractedColumn, location }, index) => {
const managedId = getManagedId(id, index);
columns[managedId] = extractedColumn;
const locations: Record<string, TinymathLocation> = {};
Object.keys(columns).forEach((k) => {
if (k.startsWith(columnId)) {
delete columns[k];
if (location) {
meta.locations[managedId] = location;
}
});
extracted.forEach(({ column, location }, index) => {
columns[getManagedId(columnId, index)] = column;
if (location) locations[getManagedId(columnId, index)] = location;
});
columns[columnId] = {
...currentColumn,
label: !currentColumn.customLabel
? text ??
columns[id] = {
...column,
label: !column.customLabel
? formula ??
i18n.translate('xpack.lens.indexPattern.formulaLabel', {
defaultMessage: 'Formula',
})
: currentColumn.label,
: column.label,
references: !isValid ? [] : [getManagedId(id, extracted.length - 1)],
params: {
...currentColumn.params,
formula: text,
...column.params,
formula,
isFormulaBroken: !isValid,
},
references: !isValid ? [] : [getManagedId(columnId, extracted.length - 1)],
} as FormulaIndexPatternColumn;
return { columns, meta };
}
/** @internal **/
export function insertOrReplaceFormulaColumn(
id: string,
column: FormulaIndexPatternColumn,
baseLayer: IndexPatternLayer,
params: ExpandColumnProperties
) {
const layer = {
...baseLayer,
columns: {
...baseLayer.columns,
[id]: {
...column,
},
},
};
const { columns: updatedColumns, meta } = Object.entries(layer.columns).reduce(
(acc, [currentColumnId, currentColumn]) => {
if (currentColumnId.startsWith(id)) {
if (currentColumnId === id && isFormulaIndexPatternColumn(currentColumn)) {
const formulaColumns = generateFormulaColumns(
currentColumnId,
currentColumn,
layer,
params
);
acc.columns = { ...acc.columns, ...formulaColumns.columns };
acc.meta = { ...acc.meta, ...formulaColumns.meta };
}
} else {
acc.columns[currentColumnId] = { ...currentColumn };
}
return acc;
},
getEmptyColumnsWithFormulaMeta()
);
return {
newLayer: {
layer: {
...layer,
columns,
columns: updatedColumns,
columnOrder: getColumnOrder({
...layer,
columns,
columns: updatedColumns,
}),
},
locations,
meta,
};
}

View file

@ -8,6 +8,7 @@
export * from './operations';
export * from './layer_helpers';
export * from './time_scale_utils';
export type {
OperationType,
BaseIndexPatternColumn,

View file

@ -36,7 +36,7 @@ import {
ReferenceBasedIndexPatternColumn,
BaseIndexPatternColumn,
} from './definitions/column_types';
import { FormulaIndexPatternColumn, regenerateLayerFromAst } from './definitions/formula';
import { FormulaIndexPatternColumn, insertOrReplaceFormulaColumn } from './definitions/formula';
import type { TimeScaleUnit } from '../../../common/expressions';
import { isColumnOfType } from './definitions/helpers';
@ -533,14 +533,9 @@ export function replaceColumn({
try {
newLayer = newColumn.params.formula
? regenerateLayerFromAst(
newColumn.params.formula,
basicLayer,
columnId,
newColumn,
? insertOrReplaceFormulaColumn(columnId, newColumn, basicLayer, {
indexPattern,
operationDefinitionMap
).newLayer
}).layer
: basicLayer;
} catch (e) {
newLayer = basicLayer;

View file

@ -38,10 +38,13 @@ export type {
OverallSumIndexPatternColumn,
} from './operations';
export type { FormulaPublicApi } from './operations/definitions/formula/formula_public_api';
export type DraggedField = DragDropIdentifier & {
field: IndexPatternField;
indexPatternId: string;
};
export interface IndexPattern {
id: string;
fields: IndexPatternField[];
@ -79,6 +82,7 @@ export interface IndexPatternPersistedState {
}
export type PersistedIndexPatternLayer = Omit<IndexPatternLayer, 'indexPatternId'>;
export interface IndexPatternPrivateState {
currentIndexPatternId: string;
layers: Record<string, IndexPatternLayer>;

View file

@ -25,6 +25,12 @@ export const lensPluginMock = {
getXyVisTypes: jest
.fn()
.mockReturnValue(new Promise((resolve) => resolve(visualizationTypes))),
stateHelperApi: jest.fn().mockResolvedValue({
formula: {
insertOrReplaceFormulaColumn: jest.fn(),
},
}),
};
return startContract;
},

View file

@ -39,6 +39,7 @@ import { IndexPatternFieldEditorStart } from '../../../../src/plugins/data_view_
import type {
IndexPatternDatasource as IndexPatternDatasourceType,
IndexPatternDatasourceSetupPlugins,
FormulaPublicApi,
} from './indexpattern_datasource';
import type {
XyVisualization as XyVisualizationType,
@ -160,6 +161,13 @@ export interface LensPublicStart {
* Method which returns xy VisualizationTypes array keeping this async as to not impact page load bundle
*/
getXyVisTypes: () => Promise<VisualizationType[]>;
/**
* API which returns state helpers keeping this async as to not impact page load bundle
*/
stateHelperApi: () => Promise<{
formula: FormulaPublicApi;
}>;
}
export class LensPlugin {
@ -387,6 +395,14 @@ export class LensPlugin {
const { visualizationTypes } = await import('./xy_visualization/types');
return visualizationTypes;
},
stateHelperApi: async () => {
const { createFormulaPublicApi } = await import('./async_services');
return {
formula: createFormulaPublicApi(),
};
},
};
}