From 2c52ac28cbfc1062ee486f5e5134d453fe7771e3 Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Mon, 17 Jan 2022 22:42:09 +0300 Subject: [PATCH] [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 * Update x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula_helper.ts Co-authored-by: Marco Liberati * remove useEffect * move baseLayer part into getLensAttributes * introduce stateHelperApi * Map -> WeakMap * remove [params.operations] from params Co-authored-by: Marco Liberati Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../embedded_lens_example/public/app.tsx | 297 +++++++++--------- .../embedded_lens_example/public/mount.tsx | 27 +- x-pack/plugins/lens/public/async_services.ts | 2 + x-pack/plugins/lens/public/index.ts | 1 + .../dimension_panel/format_selector.tsx | 9 +- .../public/indexpattern_datasource/index.ts | 2 + .../public/indexpattern_datasource/loader.ts | 150 ++++----- .../operations/definitions/column_types.ts | 9 +- .../formula/editor/formula_editor.tsx | 98 ++++-- .../definitions/formula/formula.test.tsx | 136 +++++--- .../definitions/formula/formula.tsx | 29 +- .../formula/formula_public_api.test.ts | 106 +++++++ .../definitions/formula/formula_public_api.ts | 75 +++++ .../operations/definitions/formula/index.ts | 4 +- .../operations/definitions/formula/parse.ts | 141 ++++++--- .../operations/index.ts | 1 + .../operations/layer_helpers.ts | 11 +- .../public/indexpattern_datasource/types.ts | 4 + .../lens/public/mocks/lens_plugin_mock.tsx | 6 + x-pack/plugins/lens/public/plugin.ts | 16 + 20 files changed, 728 insertions(+), 396 deletions(-) create mode 100644 x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula_public_api.test.ts create mode 100644 x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula_public_api.ts diff --git a/x-pack/examples/embedded_lens_example/public/app.tsx b/x-pack/examples/embedded_lens_example/public/app.tsx index f1b683f2430f7..510d9469c7878 100644 --- a/x-pack/examples/embedded_lens_example/public/app.tsx +++ b/x-pack/examples/embedded_lens_example/public/app.tsx @@ -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 ( @@ -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.

- {props.defaultIndexPattern && props.defaultIndexPattern.isTimeBased() ? ( - <> - - - { - // eslint-disable-next-line no-bitwise - const newColor = '#' + ((Math.random() * 0xffffff) << 0).toString(16); - setColor(newColor); - }} - > - Change color - - - - { - 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) - - - - { - props.plugins.lens.navigateToPrefilledEditor( - { - id: '', - timeRange: time, - attributes: getLensAttributes(props.defaultIndexPattern!, color), - }, - { - openInNewTab: false, - } - ); - }} - > - Edit in Lens (same tab) - - - - { - setIsSaveModalVisible(true); - }} - > - Save Visualization - - - - { - setTime({ - from: '2015-09-18T06:31:44.000Z', - to: '2015-09-23T18:31:44.000Z', - }); - }} - > - Change time range - - - - { - setIsLoading(val); + + + + { + // eslint-disable-next-line no-bitwise + const newColor = '#' + ((Math.random() * 0xffffff) << 0).toString(16); + setColor(newColor); }} - onBrushEnd={({ range }) => { - setTime({ - from: new Date(range[0]).toISOString(), - to: new Date(range[1]).toISOString(), - }); + > + Change color + + + + { + 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) + + + + { + props.plugins.lens.navigateToPrefilledEditor( + { + id: '', + timeRange: time, + attributes, + }, + { + openInNewTab: false, + } + ); }} - onFilter={(_data) => { - // call back event for on filter event + > + Edit in Lens (same tab) + + + + { + setIsSaveModalVisible(true); }} - onTableRowClick={(_data) => { - // call back event for on table row click event + > + Save Visualization + + + + { + setTime({ + from: '2015-09-18T06:31:44.000Z', + to: '2015-09-23T18:31:44.000Z', + }); }} - viewMode={ViewMode.VIEW} - /> - {isSaveModalVisible && ( - {}} - onClose={() => setIsSaveModalVisible(false)} - /> - )} - - ) : ( - -

This demo only works if your default index pattern is set and time based

-
+ > + Change time range +
+
+
+ { + 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 && ( + {}} + onClose={() => setIsSaveModalVisible(false)} + /> )} diff --git a/x-pack/examples/embedded_lens_example/public/mount.tsx b/x-pack/examples/embedded_lens_example/public/mount.tsx index 58ec363223270..e438b6946b8b6 100644 --- a/x-pack/examples/embedded_lens_example/public/mount.tsx +++ b/x-pack/examples/embedded_lens_example/public/mount.tsx @@ -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) => @@ -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 = ( - + {defaultDataView && defaultDataView.isTimeBased() ? ( + + ) : ( + +

This demo only works if your default index pattern is set and time based

+
+ )}
); + render(reactElement, element); return () => unmountComponentAtNode(element); }; diff --git a/x-pack/plugins/lens/public/async_services.ts b/x-pack/plugins/lens/public/async_services.ts index bbb4faf55e1e9..09b434c648418 100644 --- a/x-pack/plugins/lens/public/async_services.ts +++ b/x-pack/plugins/lens/public/async_services.ts @@ -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'; diff --git a/x-pack/plugins/lens/public/index.ts b/x-pack/plugins/lens/public/index.ts index 29dce6f0d1090..3e622d8ac9312 100644 --- a/x-pack/plugins/lens/public/index.ts +++ b/x-pack/plugins/lens/public/index.ts @@ -55,6 +55,7 @@ export type { FormulaIndexPatternColumn, MathIndexPatternColumn, OverallSumIndexPatternColumn, + FormulaPublicApi, } from './indexpattern_datasource/types'; export type { LensEmbeddableInput } from './embeddable'; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/format_selector.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/format_selector.tsx index dd3185b3c7990..efe7966870531 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/format_selector.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/format_selector.tsx @@ -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 = { 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); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/index.ts index 386cd7a58ae01..4301540e5bf7e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/index.ts @@ -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; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts index e1a15b87e5f5c..c61569539bec8 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts @@ -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; type IndexPatternsService = Pick; 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 } ); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts index bd5b816cd8917..2b11d182eeed0 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/column_types.ts @@ -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[]; } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/formula_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/formula_editor.tsx index fc69ea1d869f1..62a681ac3d604 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/formula_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/editor/formula_editor.tsx @@ -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); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.test.tsx index 2babd87768e32..d1561e93aa807 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.test.tsx @@ -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> = {} + partialColumn: Partial> = {} ) { - 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({ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.tsx index 15c49a7336c7e..ce0d03a232e28 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.tsx @@ -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 = { type: 'formula', @@ -150,22 +156,11 @@ export const formulaOperation: OperationDefinition ({ + 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: {} } + ); + }); +}); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula_public_api.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula_public_api.ts new file mode 100644 index 0000000000000..63255ad2bf9dc --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula_public_api.ts @@ -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 = 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; + }, + }; +}; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/index.ts index 5ff0c4e2d4bd7..cbe6efba1b859 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/index.ts @@ -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'; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/parse.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/parse.ts index ee245cc06bff9..a3b61429fb0bf 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/parse.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/parse.ts @@ -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, + operations: Record, 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; +} + +const getEmptyColumnsWithFormulaMeta = (): { + columns: Record; + meta: { + locations: Record; + }; +} => ({ + columns: {}, + meta: { + locations: {}, + }, +}); + +function generateFormulaColumns( + id: string, + column: FormulaIndexPatternColumn, layer: IndexPatternLayer, - columnId: string, - currentColumn: FormulaIndexPatternColumn, - indexPattern: IndexPattern, - operationDefinitionMap: Record + { 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 }; - - const locations: Record = {}; + extracted.forEach(({ column: extractedColumn, location }, index) => { + const managedId = getManagedId(id, index); + columns[managedId] = extractedColumn; - 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, }; } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/index.ts index b9d675716c788..4474effc8c8c8 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/index.ts @@ -8,6 +8,7 @@ export * from './operations'; export * from './layer_helpers'; export * from './time_scale_utils'; + export type { OperationType, BaseIndexPatternColumn, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts index 289161c9d3e37..dda1b16bc6c7b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts @@ -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; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/types.ts b/x-pack/plugins/lens/public/indexpattern_datasource/types.ts index a0d43c5523c5b..08786b181f3e7 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/types.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/types.ts @@ -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; + export interface IndexPatternPrivateState { currentIndexPatternId: string; layers: Record; diff --git a/x-pack/plugins/lens/public/mocks/lens_plugin_mock.tsx b/x-pack/plugins/lens/public/mocks/lens_plugin_mock.tsx index a92533a89ba67..4e713872c5a67 100644 --- a/x-pack/plugins/lens/public/mocks/lens_plugin_mock.tsx +++ b/x-pack/plugins/lens/public/mocks/lens_plugin_mock.tsx @@ -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; }, diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index 6b26753b41880..decd9d8c69510 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -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; + + /** + * 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(), + }; + }, }; }