From 3600f975de8b7db11fb5a471a74a1b34d80c1370 Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Tue, 6 Jun 2023 14:36:38 +0200 Subject: [PATCH] [Lens] Enable new context constants in formula (#158452) ## Summary Fixes #151827, Fixes #117548 This PR contains constant function features in Lens formula: * direct exposure of the `time_range()` - as suggested in #117548) Screenshot 2023-05-29 at 12 05 43 * `interval()` (as intended in #151827) Screenshot 2023-05-29 at 12 04 18 * and `now()` (as suggested in [here](https://github.com/elastic/kibana/issues/112851#issuecomment-1183175053)) functions in the formula input Screenshot 2023-05-25 at 14 30 14 A visual explanation of what each constant represent: Screenshot 2023-05-29 at 11 56 16 Documentation: Screenshot 2023-05-31 at 12 59 40 Some example usage Screenshot 2023-05-25 at 13 33 26 TSVB => Lens with `params._interval` support: ![tsvb_to_lens_with_interval](https://github.com/elastic/kibana/assets/924948/6ea86bd1-8e71-4928-b2e2-7ad27537b33d) **Notes**: * context values work like static values, with the same limits, therefore it is not possible to build a date histogram with a context value alone (i.e. a formula with only `interval()` or `now()`). It works ok without the `Date Histogram` dimension. * The `interval()` function will report an error if used without a configured `Date Histogram` dimension: Screenshot 2023-05-29 at 12 14 13 * The `interval()` function does not take into account different bucket interval size (i.e. DST changes, leap years, etc...), rather return the same value to all the buckets. This is the same behaviour as in TSVB, but in Lens it can be a problem due to the usage of `calendar_interval`. * I had to duplicate a couple of function from the helpers to avoid issues with tests. I've tried a different organization of the helpers (between pure vs impure fns) but that took longer than expected, so I would defer this task later in another PR.
General approach with `constant(...)` removed * a more general approach using `constants(value="...")` Screenshot 2023-05-29 at 12 07 23
A cloud deployment has been created to test both approaches here. Let me know which one do you prefer cc @elastic/kibana-visualizations ### Checklist Delete any items that are not applicable to this PR. - [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### Risk Matrix Delete this section if it is not applicable to this PR. Before closing this PR, invite QA, stakeholders, and other developers to identify risks that should be tested prior to the change/feature release. When forming the risk matrix, consider some of the following examples and how they may potentially impact the change: | Risk | Probability | Severity | Mitigation/Notes | |---------------------------|-------------|----------|-------------------------| | Multiple Spaces—unexpected behavior in non-default Kibana Space. | Low | High | Integration tests will verify that all features are still supported in non-default Kibana Space and when user switches between spaces. | | Multiple nodes—Elasticsearch polling might have race conditions when multiple Kibana nodes are polling for the same tasks. | High | Low | Tasks are idempotent, so executing them multiple times will not result in logical error, but will degrade performance. To test for this case we add plenty of unit tests around this logic and document manual testing procedure. | | Code should gracefully handle cases when feature X or plugin Y are disabled. | Medium | High | Unit tests will verify that any feature flag or plugin combination still results in our service operational. | | [See more potential risk examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) | ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Stratoula Kalafateli --- .../lib/convert/formula.test.ts | 28 ++ .../convert_to_lens/lib/convert/formula.ts | 5 + .../datasources/form_based/form_based.test.ts | 28 +- .../datasources/form_based/form_based.tsx | 19 +- .../formula/context_variables.test.ts | 255 +++++++++++++++++ .../definitions/formula/context_variables.tsx | 269 ++++++++++++++++++ .../formula/editor/formula_editor.tsx | 5 +- .../formula/editor/formula_help.tsx | 262 ++++++++--------- .../formula/editor/math_completion.ts | 6 +- .../definitions/formula/formula.test.tsx | 29 ++ .../definitions/formula/formula.tsx | 5 +- .../operations/definitions/formula/index.ts | 7 + .../operations/definitions/formula/parse.ts | 21 +- .../operations/definitions/index.ts | 39 ++- .../operations/layer_helpers.test.ts | 19 +- .../form_based/operations/layer_helpers.ts | 5 +- .../form_based/operations/operations.test.ts | 18 ++ .../form_based/operations/operations.ts | 18 +- .../datasources/form_based/to_expression.ts | 16 +- .../public/datasources/form_based/utils.tsx | 16 +- .../text_based/text_based_languages.test.ts | 11 +- .../editor_frame/editor_frame.tsx | 1 + .../editor_frame/expression_helpers.ts | 5 + .../editor_frame/state_helpers.ts | 4 +- .../editor_frame/suggestion_panel.test.tsx | 1 + .../editor_frame/suggestion_panel.tsx | 22 +- .../workspace_panel/workspace_panel.tsx | 2 + .../public/editor_frame_service/service.tsx | 1 + .../lens/public/mocks/datasource_mock.ts | 2 +- x-pack/plugins/lens/public/plugin.ts | 1 + x-pack/plugins/lens/public/types.ts | 1 + .../visualizations/xy/to_expression.test.ts | 3 +- x-pack/plugins/lens/tsconfig.json | 1 + 33 files changed, 933 insertions(+), 192 deletions(-) create mode 100644 x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/context_variables.test.ts create mode 100644 x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/context_variables.tsx diff --git a/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/convert/formula.test.ts b/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/convert/formula.test.ts index 0c2d06e74ec00..83cfb939e2e28 100644 --- a/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/convert/formula.test.ts +++ b/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/convert/formula.test.ts @@ -297,6 +297,34 @@ describe('convertMathToFormulaColumn', () => { expect(convertMathToFormulaColumn(...input)).toEqual(expect.objectContaining(expected)); } }); + + it.each` + expression | expected + ${'params._interval'} | ${'interval()'} + ${'params._interval + params._interval'} | ${'interval() + interval()'} + ${'params._all'} | ${null} + ${'params._all + params.interval'} | ${null} + ${'params._timestamp'} | ${null} + ${'params._timestamp + params.interval'} | ${null} + ${'params._index'} | ${null} + ${'params._index + params.interval'} | ${null} + `(`handle special params cases: $expression`, ({ expression, expected }) => { + expect( + convertMathToFormulaColumn({ + series, + metrics: [{ ...mathMetric, script: expression }], + dataView, + }) + ).toEqual( + expected + ? expect.objectContaining({ + meta: { metricId: 'some-id-1' }, + operationType: 'formula', + params: { formula: expected }, + }) + : expected + ); + }); }); describe('convertOtherAggsToFormulaColumn', () => { diff --git a/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/convert/formula.ts b/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/convert/formula.ts index eff4cee31438a..7d0da7b613441 100644 --- a/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/convert/formula.ts +++ b/src/plugins/vis_types/timeseries/public/convert_to_lens/lib/convert/formula.ts @@ -141,6 +141,11 @@ export const convertMathToFormulaColumn = ( return null; } + // now replace the _interval with the new interval() formula + if (script.includes('params._interval')) { + script = script.replaceAll('params._interval', 'interval()'); + } + const scripthasNoStaticNumber = isNaN(Number(script)); if (script.includes('params') || !scripthasNoStaticNumber) { return null; diff --git a/x-pack/plugins/lens/public/datasources/form_based/form_based.test.ts b/x-pack/plugins/lens/public/datasources/form_based/form_based.test.ts index 52d600a82d6ae..633933db750a8 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/form_based.test.ts +++ b/x-pack/plugins/lens/public/datasources/form_based/form_based.test.ts @@ -59,6 +59,8 @@ jest.mock('./dimension_panel/reference_editor', () => ({ ReferenceEditor: () => null, })); +const nowInstant = new Date(); + const fieldsOne = [ { name: 'timestamp', @@ -365,7 +367,14 @@ describe('IndexPattern Data Source', () => { it('should generate an empty expression when no columns are selected', async () => { const state = FormBasedDatasource.initialize(); expect( - FormBasedDatasource.toExpression(state, 'first', indexPatterns, dateRange, 'testing-seed') + FormBasedDatasource.toExpression( + state, + 'first', + indexPatterns, + dateRange, + nowInstant, + 'testing-seed' + ) ).toEqual(null); }); @@ -395,6 +404,7 @@ describe('IndexPattern Data Source', () => { 'first', indexPatterns, dateRange, + nowInstant, 'testing-seed' ) ).toEqual({ @@ -450,6 +460,7 @@ describe('IndexPattern Data Source', () => { 'first', indexPatterns, dateRange, + nowInstant, 'testing-seed' ) ).toMatchInlineSnapshot(` @@ -637,6 +648,7 @@ describe('IndexPattern Data Source', () => { 'first', indexPatterns, dateRange, + nowInstant, 'testing-seed' ) as Ast; expect(ast.chain[1].arguments.timeFields).toEqual(['timestamp', 'another_datefield']); @@ -678,6 +690,7 @@ describe('IndexPattern Data Source', () => { 'first', indexPatterns, dateRange, + nowInstant, 'testing-seed' ) as Ast; expect((ast.chain[1].arguments.aggs[1] as Ast).chain[0].arguments.timeShift).toEqual(['1d']); @@ -891,6 +904,7 @@ describe('IndexPattern Data Source', () => { 'first', indexPatterns, dateRange, + nowInstant, 'testing-seed' ) as Ast; const count = (ast.chain[1].arguments.aggs[1] as Ast).chain[0]; @@ -961,6 +975,7 @@ describe('IndexPattern Data Source', () => { 'first', indexPatterns, dateRange, + nowInstant, 'testing-seed' ) as Ast; expect(ast.chain[1].arguments.aggs[0]).toMatchInlineSnapshot(` @@ -1091,6 +1106,7 @@ describe('IndexPattern Data Source', () => { 'first', indexPatterns, dateRange, + nowInstant, 'testing-seed' ) as Ast; const timeScaleCalls = ast.chain.filter((fn) => fn.function === 'lens_time_scale'); @@ -1162,6 +1178,7 @@ describe('IndexPattern Data Source', () => { 'first', indexPatterns, dateRange, + nowInstant, 'testing-seed' ) as Ast; const filteredMetricAgg = (ast.chain[1].arguments.aggs[0] as Ast).chain[0].arguments; @@ -1219,6 +1236,7 @@ describe('IndexPattern Data Source', () => { 'first', indexPatterns, dateRange, + nowInstant, 'testing-seed' ) as Ast; const formatIndex = ast.chain.findIndex((fn) => fn.function === 'lens_format_column'); @@ -1273,6 +1291,7 @@ describe('IndexPattern Data Source', () => { 'first', indexPatterns, dateRange, + nowInstant, 'testing-seed' ) as Ast; expect(ast.chain[1].arguments.metricsAtAllLevels).toEqual([false]); @@ -1318,6 +1337,7 @@ describe('IndexPattern Data Source', () => { 'first', indexPatterns, dateRange, + nowInstant, 'testing-seed' ) as Ast; expect(ast.chain[1].arguments.timeFields).toEqual(['timestamp']); @@ -1381,6 +1401,7 @@ describe('IndexPattern Data Source', () => { 'first', indexPatterns, dateRange, + nowInstant, 'testing-seed' ); @@ -1455,6 +1476,7 @@ describe('IndexPattern Data Source', () => { 'first', indexPatterns, dateRange, + nowInstant, 'testing-seed' ) as Ast; @@ -1525,6 +1547,7 @@ describe('IndexPattern Data Source', () => { 'first', indexPatterns, dateRange, + nowInstant, 'testing-seed' ) as Ast; @@ -1636,6 +1659,7 @@ describe('IndexPattern Data Source', () => { 'first', indexPatterns, dateRange, + nowInstant, 'testing-seed' ) as Ast; // @ts-expect-error we can't isolate just the reference type @@ -1675,6 +1699,7 @@ describe('IndexPattern Data Source', () => { 'first', indexPatterns, dateRange, + nowInstant, 'testing-seed' ) as Ast; @@ -1768,6 +1793,7 @@ describe('IndexPattern Data Source', () => { 'first', indexPatterns, dateRange, + nowInstant, 'testing-seed' ) as Ast; const chainLength = ast.chain.length; diff --git a/x-pack/plugins/lens/public/datasources/form_based/form_based.tsx b/x-pack/plugins/lens/public/datasources/form_based/form_based.tsx index b89bb9e6e3f38..055d6a97f6183 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/form_based.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/form_based.tsx @@ -17,7 +17,7 @@ import { flatten, isEqual } from 'lodash'; 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'; +import { DataPublicPluginStart, ES_FIELD_TYPES, UI_SETTINGS } from '@kbn/data-plugin/public'; import { VisualizeFieldContext } from '@kbn/ui-actions-plugin/public'; import { ChartsPluginSetup } from '@kbn/charts-plugin/public'; import { UiActionsStart } from '@kbn/ui-actions-plugin/public'; @@ -186,7 +186,7 @@ export function getFormBasedDatasource({ return loadInitialState({ persistedState, references, - defaultIndexPatternId: core.uiSettings.get('defaultIndex'), + defaultIndexPatternId: uiSettings.get('defaultIndex'), storage, initialContext, indexPatternRefs, @@ -425,8 +425,16 @@ export function getFormBasedDatasource({ return fields; }, - toExpression: (state, layerId, indexPatterns, dateRange, searchSessionId) => - toExpression(state, layerId, indexPatterns, uiSettings, dateRange, searchSessionId), + toExpression: (state, layerId, indexPatterns, dateRange, nowInstant, searchSessionId) => + toExpression( + state, + layerId, + indexPatterns, + uiSettings, + dateRange, + nowInstant, + searchSessionId + ), renderLayerSettings(domElement, props) { render( @@ -853,7 +861,8 @@ export function getFormBasedDatasource({ layer, columnId, frameDatasourceAPI.dataViews.indexPatterns[layer.indexPatternId], - frameDatasourceAPI.dateRange + frameDatasourceAPI.dateRange, + uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET) ); } ); diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/context_variables.test.ts b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/context_variables.test.ts new file mode 100644 index 0000000000000..407f458a11e3e --- /dev/null +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/context_variables.test.ts @@ -0,0 +1,255 @@ +/* + * 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 { FormBasedLayer } from '../../../../..'; +import { createMockedIndexPattern } from '../../../mocks'; +import { DateHistogramIndexPatternColumn } from '../date_histogram'; +import { + ConstantsIndexPatternColumn, + nowOperation, + intervalOperation, + timeRangeOperation, +} from './context_variables'; + +function createLayer( + type: 'interval' | 'now' | 'time_range' +): FormBasedLayer { + return { + indexPatternId: '1', + columnOrder: ['col1'], + columns: { + col1: { + label: `Constant: ${type}`, + dataType: 'number', + operationType: type, + isBucketed: false, + scale: 'ratio', + references: [], + }, + }, + }; +} + +function createExpression(type: 'interval' | 'now' | 'time_range', value: number) { + return [ + { + type: 'function', + function: 'mathColumn', + arguments: { + id: ['col1'], + name: [`Constant: ${type}`], + expression: [String(value)], + }, + }, + ]; +} + +describe('context variables', () => { + describe('interval', () => { + describe('getErrorMessages', () => { + it('should return error if no date_histogram is configured', () => { + expect( + intervalOperation.getErrorMessage!( + createLayer('interval'), + 'col1', + createMockedIndexPattern(), + { fromDate: new Date().toISOString(), toDate: new Date().toISOString() }, + {}, + 100 + ) + ).toEqual( + expect.arrayContaining([ + 'Cannot compute an interval without a date histogram column configured', + ]) + ); + }); + + it('should return error if no dateRange is passed over', () => { + expect( + intervalOperation.getErrorMessage!( + createLayer('interval'), + 'col1', + createMockedIndexPattern(), + undefined, + {}, + 100 + ) + ).toEqual(expect.arrayContaining(['The current time range interval is not available'])); + }); + + it('should return error if no targetBar is passed over', () => { + expect( + intervalOperation.getErrorMessage!( + createLayer('interval'), + 'col1', + createMockedIndexPattern(), + { fromDate: new Date().toISOString(), toDate: new Date().toISOString() }, + {} + ) + ).toEqual(expect.arrayContaining(['Missing "histogram:barTarget" value'])); + }); + + it('should not return errors if all context is provided', () => { + const layer = createLayer('interval'); + layer.columns = { + col2: { + label: 'Date histogram', + dataType: 'date', + operationType: 'date_histogram', + sourceField: '@timestamp', + isBucketed: true, + scale: 'interval', + params: { + interval: 'auto', + includeEmptyRows: true, + dropPartials: false, + }, + } as DateHistogramIndexPatternColumn, + ...layer.columns, + }; + layer.columnOrder = ['col2', 'col1']; + expect( + intervalOperation.getErrorMessage!( + layer, + 'col1', + createMockedIndexPattern(), + { fromDate: new Date().toISOString(), toDate: new Date().toISOString() }, + {}, + 100 + ) + ).toBeUndefined(); + }); + }); + describe('toExpression', () => { + it('should return 0 if no dateRange is passed', () => { + expect( + intervalOperation.toExpression( + createLayer('interval'), + 'col1', + createMockedIndexPattern(), + { now: new Date(), targetBars: 100 } + ) + ).toEqual(expect.arrayContaining(createExpression('interval', 0))); + }); + + it('should return 0 if no targetBars is passed', () => { + expect( + intervalOperation.toExpression( + createLayer('interval'), + 'col1', + createMockedIndexPattern(), + { + dateRange: { + fromDate: new Date(2022, 0, 1).toISOString(), + toDate: new Date(2023, 0, 1).toISOString(), + }, + now: new Date(), + } + ) + ).toEqual(expect.arrayContaining(createExpression('interval', 0))); + }); + + it('should return a valid value > 0 if both dateRange and targetBars is passed', () => { + expect( + intervalOperation.toExpression( + createLayer('interval'), + 'col1', + createMockedIndexPattern(), + { + dateRange: { + fromDate: new Date(2022, 0, 1).toISOString(), + toDate: new Date(2023, 0, 1).toISOString(), + }, + now: new Date(), + targetBars: 100, + } + ) + ).toEqual(expect.arrayContaining(createExpression('interval', 86400000))); + }); + }); + }); + describe('time_range', () => { + describe('getErrorMessages', () => { + it('should return error if no dateRange is passed over', () => { + expect( + timeRangeOperation.getErrorMessage!( + createLayer('time_range'), + 'col1', + createMockedIndexPattern(), + undefined, + {}, + 100 + ) + ).toEqual(expect.arrayContaining(['The current time range interval is not available'])); + }); + + it('should return error if dataView is not time-based', () => { + const dataView = createMockedIndexPattern(); + dataView.timeFieldName = undefined; + expect( + timeRangeOperation.getErrorMessage!( + createLayer('time_range'), + 'col1', + dataView, + undefined, + {}, + 100 + ) + ).toEqual(expect.arrayContaining(['The current time range interval is not available'])); + }); + }); + + describe('toExpression', () => { + it('should return 0 if no dateRange is passed', () => { + expect( + timeRangeOperation.toExpression( + createLayer('time_range'), + 'col1', + createMockedIndexPattern(), + { now: new Date(), targetBars: 100 } + ) + ).toEqual(expect.arrayContaining(createExpression('time_range', 0))); + }); + + it('should return a valid value > 0 if dateRange is passed', () => { + expect( + timeRangeOperation.toExpression( + createLayer('time_range'), + 'col1', + createMockedIndexPattern(), + { + dateRange: { + fromDate: new Date(2022, 0, 1).toISOString(), + toDate: new Date(2023, 0, 1).toISOString(), + }, + } + ) + ).toEqual(expect.arrayContaining(createExpression('time_range', 31536000000))); + }); + }); + }); + describe('now', () => { + describe('getErrorMessages', () => { + it('should return no error even without context', () => { + expect( + nowOperation.getErrorMessage!(createLayer('now'), 'col1', createMockedIndexPattern()) + ).toBeUndefined(); + }); + }); + + describe('toExpression', () => { + it('should return the now value when passed', () => { + const now = new Date(); + expect( + nowOperation.toExpression(createLayer('now'), 'col1', createMockedIndexPattern(), { + now, + }) + ).toEqual(expect.arrayContaining(createExpression('now', +now))); + }); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/context_variables.tsx b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/context_variables.tsx new file mode 100644 index 0000000000000..4b7b5b94b493b --- /dev/null +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/context_variables.tsx @@ -0,0 +1,269 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import moment from 'moment'; +import { calcAutoIntervalNear, UI_SETTINGS } from '@kbn/data-plugin/common'; +import { partition } from 'lodash'; +import type { + DateHistogramIndexPatternColumn, + FormBasedLayer, + GenericIndexPatternColumn, +} from '../../../../..'; +import type { DateRange } from '../../../../../../common/types'; +import type { GenericOperationDefinition, OperationDefinition } from '..'; +import type { ReferenceBasedIndexPatternColumn } from '../column_types'; +import { IndexPattern } from '../../../../../types'; + +// copied over from layer_helpers +// TODO: split layer_helpers util into pure/non-pure functions to avoid issues with tests +export function getColumnOrder(layer: FormBasedLayer): string[] { + const entries = Object.entries(layer.columns); + entries.sort(([idA], [idB]) => { + const indexA = layer.columnOrder.indexOf(idA); + const indexB = layer.columnOrder.indexOf(idB); + if (indexA > -1 && indexB > -1) { + return indexA - indexB; + } else if (indexA > -1) { + return -1; + } else { + return 1; + } + }); + + const [aggregations, metrics] = partition(entries, ([, col]) => col.isBucketed); + + return aggregations.map(([id]) => id).concat(metrics.map(([id]) => id)); +} + +// Copied over from helpers +export function isColumnOfType( + type: C['operationType'], + column: GenericIndexPatternColumn +): column is C { + return column.operationType === type; +} + +export interface ContextValues { + dateRange?: DateRange; + now?: Date; + targetBars?: number; +} + +export interface TimeRangeIndexPatternColumn extends ReferenceBasedIndexPatternColumn { + operationType: 'time_range'; +} + +function getTimeRangeFromContext({ dateRange }: ContextValues) { + return dateRange ? moment(dateRange.toDate).diff(moment(dateRange.fromDate)) : 0; +} + +function getTimeRangeErrorMessages( + layer: FormBasedLayer, + columnId: string, + indexPattern: IndexPattern, + dateRange?: DateRange | undefined +) { + const errors = []; + if (!indexPattern.timeFieldName) { + errors.push( + i18n.translate('xpack.lens.indexPattern.dateRange.dataViewNoTimeBased', { + defaultMessage: 'The current dataView is not time based', + }) + ); + } + if (!dateRange) { + errors.push( + i18n.translate('xpack.lens.indexPattern.dateRange.noTimeRange', { + defaultMessage: 'The current time range interval is not available', + }) + ); + } + return errors.length ? errors : undefined; +} + +export const timeRangeOperation = createContextValueBasedOperation({ + type: 'time_range', + label: 'Time range', + description: i18n.translate('xpack.lens.indexPattern.timeRange.documentation.markdown', { + defaultMessage: ` +The specified time range, in milliseconds (ms). + `, + }), + getContextValue: getTimeRangeFromContext, + getErrorMessage: getTimeRangeErrorMessages, +}); + +export interface NowIndexPatternColumn extends ReferenceBasedIndexPatternColumn { + operationType: 'now'; +} + +function getNowFromContext({ now }: ContextValues) { + return now == null ? Date.now() : +now; +} +function getNowErrorMessage() { + return undefined; +} + +export const nowOperation = createContextValueBasedOperation({ + type: 'now', + label: 'Current now', + description: i18n.translate('xpack.lens.indexPattern.now.documentation.markdown', { + defaultMessage: ` + The current now moment used in Kibana expressed in milliseconds (ms). + `, + }), + getContextValue: getNowFromContext, + getErrorMessage: getNowErrorMessage, +}); + +export interface IntervalIndexPatternColumn extends ReferenceBasedIndexPatternColumn { + operationType: 'interval'; +} + +function getIntervalFromContext(context: ContextValues) { + return context.dateRange && context.targetBars + ? calcAutoIntervalNear(context.targetBars, getTimeRangeFromContext(context)).asMilliseconds() + : 0; +} + +function getIntervalErrorMessages( + layer: FormBasedLayer, + columnId: string, + indexPattern: IndexPattern, + dateRange?: DateRange | undefined, + operationDefinitionMap?: Record | undefined, + targetBars?: number +) { + const errors = []; + if (!targetBars) { + errors.push( + i18n.translate('xpack.lens.indexPattern.interval.noTargetBars', { + defaultMessage: `Missing "{uiSettingVar}" value`, + values: { + uiSettingVar: UI_SETTINGS.HISTOGRAM_BAR_TARGET, + }, + }) + ); + } + if (!dateRange) { + errors.push( + i18n.translate('xpack.lens.indexPattern.interval.noTimeRange', { + defaultMessage: 'The current time range interval is not available', + }) + ); + } + if ( + !Object.values(layer.columns).some((column) => + isColumnOfType('date_histogram', column) + ) + ) { + errors.push( + i18n.translate('xpack.lens.indexPattern.interval.noDateHistogramColumn', { + defaultMessage: 'Cannot compute an interval without a date histogram column configured', + }) + ); + } + return errors.length ? errors : undefined; +} + +export const intervalOperation = createContextValueBasedOperation({ + type: 'interval', + label: 'Date histogram interval', + description: i18n.translate('xpack.lens.indexPattern.interval.documentation.markdown', { + defaultMessage: ` +The specified minimum interval for the date histogram, in milliseconds (ms). + `, + }), + getContextValue: getIntervalFromContext, + getErrorMessage: getIntervalErrorMessages, +}); + +export type ConstantsIndexPatternColumn = + | IntervalIndexPatternColumn + | TimeRangeIndexPatternColumn + | NowIndexPatternColumn; + +function createContextValueBasedOperation({ + label, + type, + getContextValue, + getErrorMessage, + description, +}: { + label: string; + type: ColumnType['operationType']; + description: string; + getContextValue: (context: ContextValues) => number; + getErrorMessage: OperationDefinition['getErrorMessage']; +}): OperationDefinition { + return { + type, + displayName: label, + input: 'managedReference', + selectionStyle: 'hidden', + usedInMath: true, + getDefaultLabel: () => label, + isTransferable: () => true, + getDisabledStatus() { + return undefined; + }, + getErrorMessage, + getPossibleOperation() { + return { + dataType: 'number', + isBucketed: false, + scale: 'ratio', + }; + }, + buildColumn: () => { + return { + label, + dataType: 'number', + operationType: type, + isBucketed: false, + scale: 'ratio', + references: [], + } as unknown as ColumnType; + }, + toExpression: (layer, columnId, _, context = {}) => { + const column = layer.columns[columnId] as ColumnType; + return [ + { + type: 'function', + function: 'mathColumn', + arguments: { + id: [columnId], + name: [column.label], + expression: [String(getContextValue(context))], + }, + }, + ]; + }, + createCopy(layers, source, target) { + const currentColumn = layers[source.layerId].columns[source.columnId] as ColumnType; + const targetLayer = layers[target.layerId]; + const columns = { + ...targetLayer.columns, + [target.columnId]: { ...currentColumn }, + }; + return { + ...layers, + [target.layerId]: { + ...targetLayer, + columns, + columnOrder: getColumnOrder({ ...targetLayer, columns }), + }, + }; + }, + documentation: { + section: 'constants', + signature: '', + description, + }, + }; +} diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/editor/formula_editor.tsx b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/editor/formula_editor.tsx index c65ae57d6e97f..2cc1b8475f12f 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/editor/formula_editor.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/editor/formula_editor.tsx @@ -31,6 +31,7 @@ import { monaco } from '@kbn/monaco'; import classNames from 'classnames'; import { CodeEditor } from '@kbn/kibana-react-plugin/public'; import type { CodeEditorProps } from '@kbn/kibana-react-plugin/public'; +import { UI_SETTINGS } from '@kbn/data-plugin/public'; import { useDebounceWithOptions } from '../../../../../../shared_components'; import { ParamEditorProps } from '../..'; import { getManagedColumnsFrom } from '../../../layer_helpers'; @@ -109,6 +110,7 @@ export function FormulaEditor({ dateHistogramInterval, hasData, dateRange, + uiSettings, }: Omit, 'activeData'> & { dateHistogramInterval: ReturnType; hasData: boolean; @@ -356,7 +358,8 @@ export function FormulaEditor({ id, indexPattern, dateRange, - visibleOperationsMap + visibleOperationsMap, + uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET) ); if (messages) { const startPosition = offsetToRowColumn(text, locations[id].min); diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/editor/formula_help.tsx b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/editor/formula_help.tsx index 3c578c646a830..0911f648077c6 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/editor/formula_help.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/editor/formula_help.tsx @@ -22,6 +22,42 @@ import type { } from '../..'; import type { FormulaIndexPatternColumn } from '../formula'; +function createNewSection( + label: string, + description: string, + functionsToDocument: string[], + operationDefinitionMap: Record +) { + return { + label, + description, + items: functionsToDocument.sort().map((key) => { + const fnDescription = getFunctionDescriptionAndExamples(key, operationDefinitionMap); + return { + label: key, + description: ( + <> +

{getFunctionSignatureLabel(key, operationDefinitionMap, false)}

+ + {fnDescription ? : null} + + ), + }; + }), + }; +} + +function getFunctionDescriptionAndExamples( + label: string, + operationDefinitionMap: Record +) { + if (tinymathFunctions[label]) { + const [description, examples] = tinymathFunctions[label].help.split(`\`\`\``); + return `${description.replace(/\n/g, '\n\n')}${examples ? `\`\`\`${examples}\`\`\`` : ''}`; + } + return operationDefinitionMap[label].documentation?.description; +} + export function getDocumentationSections({ indexPattern, operationDefinitionMap, @@ -159,22 +195,12 @@ max(system.network.in.bytes, reducedTimeRange="30m") ], }); - helpGroups.push({ - label: i18n.translate('xpack.lens.formulaDocumentation.elasticsearchSection', { - defaultMessage: 'Elasticsearch', - }), - description: i18n.translate('xpack.lens.formulaDocumentation.elasticsearchSectionDescription', { - defaultMessage: - 'These functions will be executed on the raw documents for each row of the resulting table, aggregating all documents matching the break down dimensions into a single value.', - }), - items: [], - }); - const { - elasticsearch: esFunction, - calculation: calculationFunction, + elasticsearch: esFunctions, + calculation: calculationFunctions, math: mathOperations, comparison: comparisonOperations, + constants: constantsOperations, } = groupBy(getPossibleFunctions(indexPattern), (key) => { if (key in operationDefinitionMap) { return operationDefinitionMap[key].documentation?.section; @@ -185,122 +211,78 @@ max(system.network.in.bytes, reducedTimeRange="30m") }); // Es aggs - helpGroups[2].items.push( - ...esFunction.sort().map((key) => ({ - label: key, - description: ( - <> -

- {key}({operationDefinitionMap[key].documentation?.signature}) -

- - {operationDefinitionMap[key].documentation?.description ? ( - - ) : null} - - ), - })) + helpGroups.push( + createNewSection( + i18n.translate('xpack.lens.formulaDocumentation.elasticsearchSection', { + defaultMessage: 'Elasticsearch', + }), + i18n.translate('xpack.lens.formulaDocumentation.elasticsearchSectionDescription', { + defaultMessage: + 'These functions will be executed on the raw documents for each row of the resulting table, aggregating all documents matching the break down dimensions into a single value.', + }), + + esFunctions, + operationDefinitionMap + ) ); - helpGroups.push({ - label: i18n.translate('xpack.lens.formulaDocumentation.columnCalculationSection', { - defaultMessage: 'Column calculations', - }), - description: i18n.translate( - 'xpack.lens.formulaDocumentation.columnCalculationSectionDescription', - { + // Calculations aggs + helpGroups.push( + createNewSection( + i18n.translate('xpack.lens.formulaDocumentation.columnCalculationSection', { + defaultMessage: 'Column calculations', + }), + i18n.translate('xpack.lens.formulaDocumentation.columnCalculationSectionDescription', { defaultMessage: 'These functions are executed for each row, but are provided with the whole column as context. This is also known as a window function.', - } - ), - items: [], - }); + }), - // Calculations aggs - helpGroups[3].items.push( - ...calculationFunction.sort().map((key) => ({ - label: key, - description: ( - <> -

- {key}({operationDefinitionMap[key].documentation?.signature}) -

- - {operationDefinitionMap[key].documentation?.description ? ( - - ) : null} - - ), - })) + calculationFunctions, + operationDefinitionMap + ) ); - helpGroups.push({ - label: i18n.translate('xpack.lens.formulaDocumentation.mathSection', { - defaultMessage: 'Math', - }), - description: i18n.translate('xpack.lens.formulaDocumentation.mathSectionDescription', { - defaultMessage: - 'These functions will be executed for reach row of the resulting table using single values from the same row calculated using other functions.', - }), - items: [], - }); - - const mathFns = mathOperations.sort().map((key) => { - const [description, examples] = tinymathFunctions[key].help.split(`\`\`\``); - return { - label: key, - description: description.replace(/\n/g, '\n\n'), - examples: examples ? `\`\`\`${examples}\`\`\`` : '', - }; - }); - - helpGroups[4].items.push( - ...mathFns.map(({ label, description, examples }) => { - return { - label, - description: ( - <> -

{getFunctionSignatureLabel(label, operationDefinitionMap)}

+ helpGroups.push( + createNewSection( + i18n.translate('xpack.lens.formulaDocumentation.mathSection', { + defaultMessage: 'Math', + }), + i18n.translate('xpack.lens.formulaDocumentation.mathSectionDescription', { + defaultMessage: + 'These functions will be executed for reach row of the resulting table using single values from the same row calculated using other functions.', + }), - - - ), - }; - }) + mathOperations, + operationDefinitionMap + ) ); - helpGroups.push({ - label: i18n.translate('xpack.lens.formulaDocumentation.comparisonSection', { - defaultMessage: 'Comparison', - }), - description: i18n.translate('xpack.lens.formulaDocumentation.comparisonSectionDescription', { - defaultMessage: 'These functions are used to perform value comparison.', - }), - items: [], - }); - - const comparisonFns = comparisonOperations.sort().map((key) => { - const [description, examples] = tinymathFunctions[key].help.split(`\`\`\``); - return { - label: key, - description: description.replace(/\n/g, '\n\n'), - examples: examples ? `\`\`\`${examples}\`\`\`` : '', - }; - }); - - helpGroups[5].items.push( - ...comparisonFns.map(({ label, description, examples }) => { - return { - label, - description: ( - <> -

{getFunctionSignatureLabel(label, operationDefinitionMap)}

+ helpGroups.push( + createNewSection( + i18n.translate('xpack.lens.formulaDocumentation.comparisonSection', { + defaultMessage: 'Comparison', + }), + i18n.translate('xpack.lens.formulaDocumentation.comparisonSectionDescription', { + defaultMessage: 'These functions are used to perform value comparison.', + }), + + comparisonOperations, + operationDefinitionMap + ) + ); - - - ), - }; - }) + helpGroups.push( + createNewSection( + i18n.translate('xpack.lens.formulaDocumentation.constantsSection', { + defaultMessage: 'Kibana context', + }), + i18n.translate('xpack.lens.formulaDocumentation.constantsSectionDescription', { + defaultMessage: + 'These functions are used to retrieve Kibana context variables, which are the date histogram `interval`, the current `now` and the selected `time_range` and help you to compute date math operations.', + }), + constantsOperations, + operationDefinitionMap + ) ); const sections = { @@ -353,7 +335,7 @@ Use the symbols +, -, /, and * to perform basic math. export function getFunctionSignatureLabel( name: string, operationDefinitionMap: ParamEditorProps['operationDefinitionMap'], - firstParam?: { label: string | [number, number] } | null + getFullSignature: boolean = true ): string { if (tinymathFunctions[name]) { return `${name}(${tinymathFunctions[name].positionalArguments @@ -363,26 +345,28 @@ export function getFunctionSignatureLabel( if (operationDefinitionMap[name]) { const def = operationDefinitionMap[name]; const extraArgs: string[] = []; - if (def.filterable) { - extraArgs.push( - i18n.translate('xpack.lens.formula.kqlExtraArguments', { - defaultMessage: '[kql]?: string, [lucene]?: string', - }) - ); - } - if (def.shiftable) { - extraArgs.push( - i18n.translate('xpack.lens.formula.shiftExtraArguments', { - defaultMessage: '[shift]?: string', - }) - ); - } - if (def.canReduceTimeRange) { - extraArgs.push( - i18n.translate('xpack.lens.formula.reducedTimeRangeExtraArguments', { - defaultMessage: '[reducedTimeRange]?: string', - }) - ); + if (getFullSignature) { + if (def.filterable) { + extraArgs.push( + i18n.translate('xpack.lens.formula.kqlExtraArguments', { + defaultMessage: '[kql]?: string, [lucene]?: string', + }) + ); + } + if (def.shiftable) { + extraArgs.push( + i18n.translate('xpack.lens.formula.shiftExtraArguments', { + defaultMessage: '[shift]?: string', + }) + ); + } + if (def.canReduceTimeRange) { + extraArgs.push( + i18n.translate('xpack.lens.formula.reducedTimeRangeExtraArguments', { + defaultMessage: '[reducedTimeRange]?: string', + }) + ); + } } const extraComma = extraArgs.length ? ', ' : ''; return `${name}(${def.documentation?.signature}${extraComma}${extraArgs.join(', ')})`; diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/editor/math_completion.ts b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/editor/math_completion.ts index 773dd5c97009e..baa176a1cb514 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/editor/math_completion.ts +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/editor/math_completion.ts @@ -219,7 +219,9 @@ export function getPossibleFunctions( available.forEach((a) => { if (a.operationMetaData.dataType === 'number' && !a.operationMetaData.isBucketed) { possibleOperationNames.push( - ...a.operations.filter((o) => o.type !== 'managedReference').map((o) => o.operationType) + ...a.operations + .filter((o) => o.type !== 'managedReference' || o.usedInMath) + .map((o) => o.operationType) ); } }); @@ -607,7 +609,7 @@ function getSignaturesForFunction( } : null; - const functionLabel = getFunctionSignatureLabel(name, operationDefinitionMap, firstParam); + const functionLabel = getFunctionSignatureLabel(name, operationDefinitionMap); const documentation = getOperationTypeHelp(name, operationDefinitionMap); if ('operationParams' in def && def.operationParams) { return [ diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/formula.test.tsx b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/formula.test.tsx index bc711e7d094c1..ea77bc3e429ab 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/formula.test.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/formula.test.tsx @@ -73,6 +73,13 @@ const operationDefinitionMap: Record = { }), }), cumulative_sum: createOperationDefinitionMock('cumulative_sum', { input: 'fullReference' }), + interval: createOperationDefinitionMock('interval', { + input: 'managedReference', + usedInMath: true, + }), + opertion_not_available: createOperationDefinitionMock('operation_not_available', { + input: 'managedReference', + }), }; describe('formula', () => { @@ -1871,5 +1878,27 @@ invalid: " ) ).toHaveLength(1); }); + + it('should work with managed reference operations only when "usedInMath" flag is enabled', () => { + expect( + formulaOperation.getErrorMessage!( + getNewLayerWithFormula('interval()', false), + 'col1', + indexPattern, + undefined, + operationDefinitionMap + ) + ).toEqual(undefined); + + expect( + formulaOperation.getErrorMessage!( + getNewLayerWithFormula('operation_not_available()', false), + 'col1', + indexPattern, + undefined, + operationDefinitionMap + ) + ).toEqual(['Operation operation_not_available not found']); + }); }); }); diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/formula.tsx b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/formula.tsx index c61cf7685682a..7d2a457f040dd 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/formula.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/formula/formula.tsx @@ -68,7 +68,7 @@ export const formulaOperation: OperationDefinition { const columns: Array<{ column: GenericIndexPatternColumn; location?: TinymathLocation }> = []; const { filter: globalFilter, reducedTimeRange: globalReducedTimeRange } = @@ -194,6 +196,21 @@ function extractColumns( // replace by new column id return newColId; } + + if (nodeOperation.input === 'managedReference' && nodeOperation.usedInMath) { + const newCol = ( + nodeOperation as OperationDefinition + ).buildColumn({ + layer, + indexPattern, + }); + const newColId = getManagedId(idPrefix, columns.length); + newCol.customLabel = true; + newCol.label = label; + columns.push({ column: newCol, location: node.location }); + // replace by new column id + return newColId; + } } const root = parseNode(ast); diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/index.ts b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/index.ts index aaba48eae39d0..de55eea485869 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/index.ts +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/index.ts @@ -41,7 +41,13 @@ import { timeScaleOperation, } from './calculations'; import { countOperation } from './count'; -import { mathOperation, formulaOperation } from './formula'; +import { + mathOperation, + formulaOperation, + timeRangeOperation, + nowOperation, + intervalOperation, +} from './formula'; import { staticValueOperation } from './static_value'; import { lastValueOperation } from './last_value'; import type { @@ -100,7 +106,13 @@ export type { export type { CountIndexPatternColumn } from './count'; export type { LastValueIndexPatternColumn } from './last_value'; export type { RangeIndexPatternColumn } from './ranges'; -export type { FormulaIndexPatternColumn, MathIndexPatternColumn } from './formula'; +export type { + FormulaIndexPatternColumn, + MathIndexPatternColumn, + TimeRangeIndexPatternColumn, + NowIndexPatternColumn, + IntervalIndexPatternColumn, +} from './formula'; export type { StaticValueIndexPatternColumn } from './static_value'; // List of all operation definitions registered to this data source. @@ -134,6 +146,9 @@ const internalOperationDefinitions = [ overallAverageOperation, staticValueOperation, timeScaleOperation, + timeRangeOperation, + nowOperation, + intervalOperation, ]; export { termsOperation } from './terms'; @@ -308,7 +323,8 @@ interface BaseOperationDefinitionProps< columnId: string, indexPattern: IndexPattern, dateRange?: DateRange, - operationDefinitionMap?: Record + operationDefinitionMap?: Record, + targetBars?: number ) => FieldBasedOperationErrorMessage[] | undefined; /* @@ -338,7 +354,7 @@ interface BaseOperationDefinitionProps< documentation?: { signature: string; description: string; - section: 'elasticsearch' | 'calculation'; + section: 'elasticsearch' | 'calculation' | 'constants'; }; quickFunctionDocumentation?: string; /** @@ -665,7 +681,8 @@ interface ManagedReferenceOperationDefinition toExpression: ( layer: FormBasedLayer, columnId: string, - indexPattern: IndexPattern + indexPattern: IndexPattern, + context?: { dateRange?: DateRange; now?: Date; targetBars?: number } ) => ExpressionAstFunction[]; /** * Managed references control the IDs of their inner columns, so we need to be able to copy from the @@ -677,6 +694,18 @@ interface ManagedReferenceOperationDefinition target: DataViewDragDropOperation, operationDefinitionMap: Record ) => Record; + + /** + * Special managed columns can be used in a formula + */ + usedInMath?: boolean; + + /** + * The specification of the arguments used by the operations used for both validation, + * and use from external managed operations + */ + operationParams?: OperationParam[]; + selectionStyle?: 'hidden'; } interface OperationDefinitionMap { diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/layer_helpers.test.ts b/x-pack/plugins/lens/public/datasources/form_based/operations/layer_helpers.test.ts index 0bdf9aa0bc795..05ee8de63fc51 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/layer_helpers.test.ts +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/layer_helpers.test.ts @@ -39,9 +39,9 @@ import { OperationDefinition, } from './definitions'; import { TinymathAST } from '@kbn/tinymath'; -import { CoreStart } from '@kbn/core/public'; import { IndexPattern } from '../../../types'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; +import { createCoreStartMock } from '@kbn/core-lifecycle-browser-mocks/src/core_start.mock'; const dataMock = dataPluginMock.createStartContract(); dataMock.query.timefilter.timefilter.getAbsoluteTime = jest @@ -53,6 +53,10 @@ jest.mock('../../../id_generator'); jest.mock('../dimension_panel/reference_editor', () => ({ ReferenceEditor: () => null, })); +const TARGET_BAR_COUNT = 100; + +const CoreStartMock = createCoreStartMock(); +CoreStartMock.uiSettings.get.mockReturnValue(TARGET_BAR_COUNT); const indexPatternFields = [ { @@ -3128,7 +3132,7 @@ describe('state_helpers', () => { indexPattern, {}, '1', - {}, + CoreStartMock, dataMock ); expect(mock).toHaveBeenCalled(); @@ -3155,7 +3159,7 @@ describe('state_helpers', () => { indexPattern, {} as FormBasedPrivateState, '1', - {} as CoreStart, + CoreStartMock, dataMock ); expect(mock).toHaveBeenCalled(); @@ -3191,7 +3195,7 @@ describe('state_helpers', () => { indexPattern, {} as FormBasedPrivateState, '1', - {} as CoreStart, + CoreStartMock, dataMock ); expect(notCalledMock).not.toHaveBeenCalled(); @@ -3228,7 +3232,7 @@ describe('state_helpers', () => { indexPattern, {} as FormBasedPrivateState, '1', - {} as CoreStart, + CoreStartMock, dataMock ); expect(savedRef).toHaveBeenCalled(); @@ -3258,7 +3262,7 @@ describe('state_helpers', () => { indexPattern, {} as FormBasedPrivateState, '1', - {} as CoreStart, + CoreStartMock, dataMock ); expect(mock).toHaveBeenCalledWith( @@ -3281,7 +3285,8 @@ describe('state_helpers', () => { fromDate: '2022-11-01T00:00:00.000Z', toDate: '2022-11-03T00:00:00.000Z', }, - operationDefinitionMap + operationDefinitionMap, + TARGET_BAR_COUNT ); }); }); diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/layer_helpers.ts b/x-pack/plugins/lens/public/datasources/form_based/operations/layer_helpers.ts index e6ac80c439083..06e2944a0d978 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/layer_helpers.ts +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/layer_helpers.ts @@ -9,7 +9,7 @@ import { partition, mapValues, pickBy } from 'lodash'; import { CoreStart } from '@kbn/core/public'; import type { Query } from '@kbn/es-query'; import memoizeOne from 'memoize-one'; -import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import { DataPublicPluginStart, UI_SETTINGS } from '@kbn/data-plugin/public'; import type { DateRange } from '../../../../common/types'; import type { DatasourceFixAction, @@ -1580,7 +1580,8 @@ export function getErrorMessages( columnId, indexPattern, { fromDate: currentTimeRange.from, toDate: currentTimeRange.to }, - operationDefinitionMap + operationDefinitionMap, + core.uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET) ); } }) diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/operations.test.ts b/x-pack/plugins/lens/public/datasources/form_based/operations/operations.test.ts index 967406a4bfbc9..1f6f041b4eee2 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/operations.test.ts +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/operations.test.ts @@ -442,10 +442,27 @@ describe('getOperationTypesForField', () => { Object { "operationType": "math", "type": "managedReference", + "usedInMath": undefined, }, Object { "operationType": "formula", "type": "managedReference", + "usedInMath": undefined, + }, + Object { + "operationType": "time_range", + "type": "managedReference", + "usedInMath": true, + }, + Object { + "operationType": "now", + "type": "managedReference", + "usedInMath": true, + }, + Object { + "operationType": "interval", + "type": "managedReference", + "usedInMath": true, }, ], }, @@ -498,6 +515,7 @@ describe('getOperationTypesForField', () => { Object { "operationType": "static_value", "type": "managedReference", + "usedInMath": undefined, }, ], }, diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/operations.ts b/x-pack/plugins/lens/public/datasources/form_based/operations/operations.ts index 151081e190a68..cf7cbb48d4fd9 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/operations.ts +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/operations.ts @@ -164,6 +164,7 @@ export type OperationFieldTuple = | { type: 'managedReference'; operationType: OperationType; + usedInMath?: boolean; }; /** @@ -203,7 +204,11 @@ export function getAvailableOperationsByMetadata( ) { const operationByMetadata: Record< string, - { operationMetaData: OperationMetadata; operations: OperationFieldTuple[] } + { + operationMetaData: OperationMetadata; + operations: OperationFieldTuple[]; + usedInMath?: boolean; + } > = {}; const addToMap = ( @@ -255,7 +260,10 @@ export function getAvailableOperationsByMetadata( const validOperation = operationDefinition.getPossibleOperation(indexPattern); if (validOperation) { addToMap( - { type: 'fullReference', operationType: operationDefinition.type }, + { + type: 'fullReference', + operationType: operationDefinition.type, + }, validOperation ); } @@ -263,7 +271,11 @@ export function getAvailableOperationsByMetadata( const validOperation = operationDefinition.getPossibleOperation(); if (validOperation) { addToMap( - { type: 'managedReference', operationType: operationDefinition.type }, + { + type: 'managedReference', + operationType: operationDefinition.type, + usedInMath: operationDefinition.usedInMath, + }, validOperation ); } diff --git a/x-pack/plugins/lens/public/datasources/form_based/to_expression.ts b/x-pack/plugins/lens/public/datasources/form_based/to_expression.ts index 8781e63de1cbe..e36bacabd3b10 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/to_expression.ts +++ b/x-pack/plugins/lens/public/datasources/form_based/to_expression.ts @@ -8,10 +8,11 @@ import type { IUiSettingsClient } from '@kbn/core/public'; import { partition, uniq } from 'lodash'; import seedrandom from 'seedrandom'; -import type { +import { AggFunctionsMapping, EsaggsExpressionFunctionDefinition, IndexPatternLoadExpressionFunctionDefinition, + UI_SETTINGS, } from '@kbn/data-plugin/public'; import { queryToAst } from '@kbn/data-plugin/common'; import { @@ -58,6 +59,7 @@ function getExpressionForLayer( indexPattern: IndexPattern, uiSettings: IUiSettingsClient, dateRange: DateRange, + nowInstant: Date, searchSessionId?: string ): ExpressionAstExpression | null { const { columnOrder } = layer; @@ -132,19 +134,25 @@ function getExpressionForLayer( if (referenceEntries.length || esAggEntries.length) { let aggs: ExpressionAstExpressionBuilder[] = []; const expressions: ExpressionAstFunction[] = []; + const histogramBarsTarget = uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); sortedReferences(referenceEntries).forEach((colId) => { const col = columns[colId]; const def = operationDefinitionMap[col.operationType]; if (def.input === 'fullReference' || def.input === 'managedReference') { - expressions.push(...def.toExpression(layer, colId, indexPattern)); + expressions.push( + ...def.toExpression(layer, colId, indexPattern, { + dateRange, + now: nowInstant, + targetBars: histogramBarsTarget, + }) + ); } }); const orderedColumnIds = esAggEntries.map(([colId]) => colId); let esAggsIdMap: Record = {}; const aggExpressionToEsAggsIdMap: Map = new Map(); - const histogramBarsTarget = uiSettings.get('histogram:barTarget'); esAggEntries.forEach(([colId, col], index) => { const def = operationDefinitionMap[col.operationType]; if (def.input !== 'fullReference' && def.input !== 'managedReference') { @@ -469,6 +477,7 @@ export function toExpression( indexPatterns: IndexPatternMap, uiSettings: IUiSettingsClient, dateRange: DateRange, + nowInstant: Date, searchSessionId?: string ) { if (state.layers[layerId]) { @@ -477,6 +486,7 @@ export function toExpression( indexPatterns[state.layers[layerId].indexPatternId], uiSettings, dateRange, + nowInstant, searchSessionId ); } diff --git a/x-pack/plugins/lens/public/datasources/form_based/utils.tsx b/x-pack/plugins/lens/public/datasources/form_based/utils.tsx index e967a468dc851..4365eba33186e 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/utils.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/utils.tsx @@ -101,7 +101,8 @@ export function isColumnInvalid( layer: FormBasedLayer, columnId: string, indexPattern: IndexPattern, - dateRange: DateRange | undefined + dateRange: DateRange | undefined, + targetBars: number ) { const column: GenericIndexPatternColumn | undefined = layer.columns[columnId]; if (!column || !indexPattern) return; @@ -111,7 +112,9 @@ export function isColumnInvalid( const referencesHaveErrors = true && 'references' in column && - Boolean(getReferencesErrors(layer, column, indexPattern, dateRange).filter(Boolean).length); + Boolean( + getReferencesErrors(layer, column, indexPattern, dateRange, targetBars).filter(Boolean).length + ); const operationErrorMessages = operationDefinition && @@ -120,7 +123,8 @@ export function isColumnInvalid( columnId, indexPattern, dateRange, - operationDefinitionMap + operationDefinitionMap, + targetBars ); // it looks like this is just a back-stop since we prevent @@ -138,7 +142,8 @@ function getReferencesErrors( layer: FormBasedLayer, column: ReferenceBasedIndexPatternColumn, indexPattern: IndexPattern, - dateRange: DateRange | undefined + dateRange: DateRange | undefined, + targetBars: number ) { return column.references?.map((referenceId: string) => { const referencedOperation = layer.columns[referenceId]?.operationType; @@ -148,7 +153,8 @@ function getReferencesErrors( referenceId, indexPattern, dateRange, - operationDefinitionMap + operationDefinitionMap, + targetBars ); }); } diff --git a/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.test.ts b/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.test.ts index bc173bab4aca4..124dc1aa97d26 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.test.ts +++ b/x-pack/plugins/lens/public/datasources/text_based/text_based_languages.test.ts @@ -680,9 +680,9 @@ describe('Textbased Data Source', () => { describe('#toExpression', () => { it('should generate an empty expression when no columns are selected', async () => { const state = TextBasedDatasource.initialize(); - expect(TextBasedDatasource.toExpression(state, 'first', indexPatterns, dateRange)).toEqual( - null - ); + expect( + TextBasedDatasource.toExpression(state, 'first', indexPatterns, dateRange, new Date()) + ).toEqual(null); }); it('should generate an expression for an SQL query', async () => { @@ -732,8 +732,9 @@ describe('Textbased Data Source', () => { ], } as unknown as TextBasedPrivateState; - expect(TextBasedDatasource.toExpression(queryBaseState, 'a', indexPatterns, dateRange)) - .toMatchInlineSnapshot(` + expect( + TextBasedDatasource.toExpression(queryBaseState, 'a', indexPatterns, dateRange, new Date()) + ).toMatchInlineSnapshot(` Object { "chain": Array [ Object { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx index 9051a42562f95..a7e1e1449ad2f 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx @@ -178,6 +178,7 @@ export function EditorFrame(props: EditorFrameProps) { visualizationMap={visualizationMap} frame={framePublicAPI} getUserMessages={props.getUserMessages} + nowProvider={props.plugins.data.nowProvider} /> ) diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/expression_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/expression_helpers.ts index 7d2c642e21602..99a61cd99cb30 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/expression_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/expression_helpers.ts @@ -14,6 +14,7 @@ export function getDatasourceExpressionsByLayers( datasourceStates: DatasourceStates, indexPatterns: IndexPatternMap, dateRange: DateRange, + nowInstant: Date, searchSessionId?: string ): null | Record { const datasourceExpressions: Array<[string, Ast | string]> = []; @@ -32,6 +33,7 @@ export function getDatasourceExpressionsByLayers( layerId, indexPatterns, dateRange, + nowInstant, searchSessionId ); if (result) { @@ -63,6 +65,7 @@ export function buildExpression({ description, indexPatterns, dateRange, + nowInstant, searchSessionId, }: { title?: string; @@ -75,6 +78,7 @@ export function buildExpression({ indexPatterns: IndexPatternMap; searchSessionId?: string; dateRange: DateRange; + nowInstant: Date; }): Ast | null { if (visualization === null) { return null; @@ -85,6 +89,7 @@ export function buildExpression({ datasourceStates, indexPatterns, dateRange, + nowInstant, searchSessionId ); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts index 5731160edc63d..97611b62694d1 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts @@ -12,7 +12,7 @@ import { difference } from 'lodash'; import type { DataViewsContract, DataViewSpec } from '@kbn/data-views-plugin/public'; import { IStorageWrapper } from '@kbn/kibana-utils-plugin/public'; import { DataViewPersistableStateService } from '@kbn/data-views-plugin/common'; -import type { TimefilterContract } from '@kbn/data-plugin/public'; +import type { DataPublicPluginStart, TimefilterContract } from '@kbn/data-plugin/public'; import { EventAnnotationServiceType } from '@kbn/event-annotation-plugin/public'; import { EventAnnotationGroupConfig, @@ -346,6 +346,7 @@ export async function persistedStateToExpression( storage: IStorageWrapper; dataViews: DataViewsContract; timefilter: TimefilterContract; + nowProvider: DataPublicPluginStart['nowProvider']; eventAnnotationService: EventAnnotationServiceType; } ): Promise { @@ -432,6 +433,7 @@ export async function persistedStateToExpression( datasourceLayers, indexPatterns, dateRange: { fromDate: currentTimeRange.from, toDate: currentTimeRange.to }, + nowInstant: services.nowProvider.get(), }), activeVisualizationState, indexPatterns, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx index 02b6b92255019..6d002873a16a3 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx @@ -105,6 +105,7 @@ describe('suggestion_panel', () => { ExpressionRenderer: expressionRendererMock, frame: createMockFramePublicAPI(), getUserMessages: () => [], + nowProvider: { get: jest.fn(() => new Date()) }, }; }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx index 8848e7cb82c5d..3985c4f12a047 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx @@ -25,7 +25,7 @@ import { IconType } from '@elastic/eui/src/components/icon/icon'; import { Ast, fromExpression, toExpression } from '@kbn/interpreter'; import { i18n } from '@kbn/i18n'; import classNames from 'classnames'; -import { ExecutionContextSearch } from '@kbn/data-plugin/public'; +import { DataPublicPluginStart, ExecutionContextSearch } from '@kbn/data-plugin/public'; import { ReactExpressionRendererProps, ReactExpressionRendererType, @@ -100,6 +100,7 @@ export interface SuggestionPanelProps { ExpressionRenderer: ReactExpressionRendererType; frame: FramePublicAPI; getUserMessages: UserMessagesGetter; + nowProvider: DataPublicPluginStart['nowProvider']; } const PreviewRenderer = ({ @@ -219,6 +220,7 @@ export function SuggestionPanel({ frame, ExpressionRenderer: ExpressionRendererComponent, getUserMessages, + nowProvider, }: SuggestionPanelProps) { const dispatchLens = useLensDispatch(); const activeDatasourceId = useLensSelector(selectActiveDatasourceId); @@ -289,7 +291,8 @@ export function SuggestionPanel({ visualizationMap[suggestion.visualizationId], datasourceMap, currentDatasourceStates, - frame + frame, + nowProvider ), })); @@ -303,7 +306,8 @@ export function SuggestionPanel({ visualizationMap[currentVisualization.activeId], datasourceMap, currentDatasourceStates, - frame + frame, + nowProvider ) : undefined; @@ -508,7 +512,8 @@ function getPreviewExpression( visualization: Visualization, datasources: Record, datasourceStates: DatasourceStates, - frame: FramePublicAPI + frame: FramePublicAPI, + nowProvider: DataPublicPluginStart['nowProvider'] ) { if (!visualization.toPreviewExpression) { return null; @@ -546,7 +551,8 @@ function getPreviewExpression( datasources, datasourceStates, frame.dataViews.indexPatterns, - frame.dateRange + frame.dateRange, + nowProvider.get() ); return visualization.toPreviewExpression( @@ -565,7 +571,8 @@ function preparePreviewExpression( visualization: Visualization, datasourceMap: DatasourceMap, datasourceStates: DatasourceStates, - framePublicAPI: FramePublicAPI + framePublicAPI: FramePublicAPI, + nowProvider: DataPublicPluginStart['nowProvider'] ) { const suggestionDatasourceId = visualizableState.datasourceId; const suggestionDatasourceState = visualizableState.datasourceState; @@ -585,7 +592,8 @@ function preparePreviewExpression( visualization, datasourceMap, datasourceStatesWithSuggestions, - framePublicAPI + framePublicAPI, + nowProvider ); if (!expression) { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx index b5aba881329b1..bf7ad500058e7 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx @@ -301,6 +301,7 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ datasourceLayers, indexPatterns: dataViews.indexPatterns, dateRange: framePublicAPI.dateRange, + nowInstant: plugins.data.nowProvider.get(), searchSessionId, }); @@ -347,6 +348,7 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ datasourceLayers, dataViews.indexPatterns, framePublicAPI.dateRange, + plugins.data.nowProvider, searchSessionId, addUserMessages, ]); diff --git a/x-pack/plugins/lens/public/editor_frame_service/service.tsx b/x-pack/plugins/lens/public/editor_frame_service/service.tsx index 939cd9da38c4b..a24d255839afd 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/service.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/service.tsx @@ -59,6 +59,7 @@ export interface EditorFramePlugins { uiSettings: IUiSettingsClient; storage: IStorageWrapper; timefilter: TimefilterContract; + nowProvider: DataPublicPluginStart['nowProvider']; eventAnnotationService: EventAnnotationServiceType; } diff --git a/x-pack/plugins/lens/public/mocks/datasource_mock.ts b/x-pack/plugins/lens/public/mocks/datasource_mock.ts index 820b43b4103d3..567c5c438f546 100644 --- a/x-pack/plugins/lens/public/mocks/datasource_mock.ts +++ b/x-pack/plugins/lens/public/mocks/datasource_mock.ts @@ -46,7 +46,7 @@ export function createMockDatasource( initialize: jest.fn((_state?) => {}), renderDataPanel: jest.fn(), renderLayerPanel: jest.fn(), - toExpression: jest.fn((_frame, _state, _indexPatterns, dateRange) => null), + toExpression: jest.fn((_frame, _state, _indexPatterns, dateRange, nowInstant) => null), insertLayer: jest.fn((_state, _newLayerId) => ({})), removeLayer: jest.fn((state, layerId) => ({ newState: state, removedLayerIds: [layerId] })), cloneLayer: jest.fn((_state, _layerId, _newLayerId, getNewId) => {}), diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index 40380f8674907..7b772c858db9f 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -332,6 +332,7 @@ export class LensPlugin { storage: new Storage(localStorage), uiSettings: core.uiSettings, timefilter: plugins.data.query.timefilter.timefilter, + nowProvider: plugins.data.nowProvider, eventAnnotationService, }), injectFilterReferences: data.query.filterManager.inject.bind(data.query.filterManager), diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 70efe28d0852d..d6575e3940490 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -437,6 +437,7 @@ export interface Datasource { layerId: string, indexPatterns: IndexPatternMap, dateRange: DateRange, + nowInstant: Date, searchSessionId?: string ) => ExpressionAstExpression | string | null; diff --git a/x-pack/plugins/lens/public/visualizations/xy/to_expression.test.ts b/x-pack/plugins/lens/public/visualizations/xy/to_expression.test.ts index 589c0d5995784..a1378a1442698 100644 --- a/x-pack/plugins/lens/public/visualizations/xy/to_expression.test.ts +++ b/x-pack/plugins/lens/public/visualizations/xy/to_expression.test.ts @@ -61,7 +61,8 @@ describe('#toExpression', () => { frame.datasourceLayers.first, 'first', frame.dataViews.indexPatterns, - frame.dateRange + frame.dateRange, + new Date() ) ?? { type: 'expression', chain: [], diff --git a/x-pack/plugins/lens/tsconfig.json b/x-pack/plugins/lens/tsconfig.json index d3abd46685d1b..05ab37120b8a5 100644 --- a/x-pack/plugins/lens/tsconfig.json +++ b/x-pack/plugins/lens/tsconfig.json @@ -77,6 +77,7 @@ "@kbn/core-lifecycle-browser", "@kbn/core-notifications-browser-mocks", "@kbn/core-saved-objects-utils-server", + "@kbn/core-lifecycle-browser-mocks", ], "exclude": [ "target/**/*",