diff --git a/x-pack/platform/plugins/shared/lens/public/app_plugin/shared/edit_on_the_fly/use_esql_conversion.tsx b/x-pack/platform/plugins/shared/lens/public/app_plugin/shared/edit_on_the_fly/use_esql_conversion.tsx index 1d949426e7863..ed54d8571af16 100644 --- a/x-pack/platform/plugins/shared/lens/public/app_plugin/shared/edit_on_the_fly/use_esql_conversion.tsx +++ b/x-pack/platform/plugins/shared/lens/public/app_plugin/shared/edit_on_the_fly/use_esql_conversion.tsx @@ -15,17 +15,19 @@ import type { import { partition } from 'lodash'; import type { CoreStart } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; -import { getESQLForLayer } from '../../../datasources/form_based/to_esql'; +import { getESQLForLayer, isEsqlQuerySuccess } from '../../../datasources/form_based/to_esql'; +import { + esqlConversionFailureReasonMessages, + getFailureTooltip, +} from '../../../datasources/form_based/to_esql_failure_reasons'; import type { ConvertibleLayer } from './convert_to_esql_modal'; import { operationDefinitionMap } from '../../../datasources/form_based/operations'; import type { LensPluginStartDependencies } from '../../../plugin'; import { layerTypes } from '../../..'; -const cannotConvertToEsqlTooltip = i18n.translate('xpack.lens.config.cannotConvertToEsqlTooltip', { - defaultMessage: 'This visualization cannot be converted to ES|QL', -}); - -const getEsqlConversionDisabledSettings = (tooltip: string = cannotConvertToEsqlTooltip) => ({ +const getEsqlConversionDisabledSettings = ( + tooltip: string = esqlConversionFailureReasonMessages.unknown +) => ({ isConvertToEsqlButtonDisabled: true, convertToEsqlButtonTooltip: tooltip, convertibleLayers: [], @@ -74,18 +76,14 @@ export const useEsqlConversion = ( // Guard: trendline check if (hasTrendLineLayer(state)) { return getEsqlConversionDisabledSettings( - i18n.translate('xpack.lens.config.cannotConvertToEsqlMetricWithTrendlineTooltip', { - defaultMessage: 'Metric visualization with a trend line are not supported in query mode', - }) + esqlConversionFailureReasonMessages.trend_line_not_supported ); } // Guard: layer count if (layerIds.length > 1) { return getEsqlConversionDisabledSettings( - i18n.translate('xpack.lens.config.cannotConvertToEsqlMultilayerTooltip', { - defaultMessage: 'Multi-layer visualizations cannot be converted to query mode', - }) + esqlConversionFailureReasonMessages.multi_layer_not_supported ); } @@ -134,7 +132,7 @@ export const useEsqlConversion = ( // This prevents conversion errors from breaking the visualization } - return esqlLayer + return isEsqlQuerySuccess(esqlLayer) ? { isConvertToEsqlButtonDisabled: false, convertToEsqlButtonTooltip: i18n.translate('xpack.lens.config.convertToEsqlTooltip', { @@ -151,11 +149,7 @@ export const useEsqlConversion = ( }, ], } - : getEsqlConversionDisabledSettings( - i18n.translate('xpack.lens.config.cannotConvertToEsqlUnsupportedSettingsTooltip', { - defaultMessage: 'The visualization has unsupported settings for query mode', - }) - ); + : getEsqlConversionDisabledSettings(getFailureTooltip(esqlLayer?.reason)); }, [ activeVisualization, coreStart.uiSettings, diff --git a/x-pack/platform/plugins/shared/lens/public/datasources/form_based/to_esql.test.ts b/x-pack/platform/plugins/shared/lens/public/datasources/form_based/to_esql.test.ts index 9cc61980be797..e9266776c4bf2 100644 --- a/x-pack/platform/plugins/shared/lens/public/datasources/form_based/to_esql.test.ts +++ b/x-pack/platform/plugins/shared/lens/public/datasources/form_based/to_esql.test.ts @@ -81,15 +81,18 @@ describe('to_esql', () => { new Date() ); - expect(esql?.esql).toEqual( - `FROM myIndexPattern + expect(esql).toEqual( + expect.objectContaining({ + success: true, + esql: `FROM myIndexPattern | WHERE order_date >= ?_tstart AND order_date <= ?_tend | STATS bucket_0_0 = COUNT(*) BY order_date = BUCKET(order_date, 30 minutes) - | SORT order_date ASC` + | SORT order_date ASC`, + }) ); }); - it('should return undefined if missing row option is set', () => { + it('should return failure with include_empty_rows_not_supported reason if missing row option is set', () => { const esql = getESQLForLayer( [ [ @@ -124,10 +127,13 @@ describe('to_esql', () => { new Date() ); - expect(esql?.esql).toEqual(undefined); + expect(esql).toEqual({ + success: false, + reason: 'include_empty_rows_not_supported', + }); }); - it('should return undefined if lens formula is used', () => { + it('should return failure with formula_not_supported reason if lens formula is used', () => { const esql = getESQLForLayer( [ [ @@ -151,7 +157,10 @@ describe('to_esql', () => { new Date() ); - expect(esql).toEqual(undefined); + expect(esql).toEqual({ + success: false, + reason: 'formula_not_supported', + }); }); test('it should add a where condition to esql if timeField is set', () => { @@ -189,11 +198,14 @@ describe('to_esql', () => { new Date() ); - expect(esql?.esql).toEqual( - `FROM myIndexPattern + expect(esql).toEqual( + expect.objectContaining({ + success: true, + esql: `FROM myIndexPattern | WHERE order_date >= ?_tstart AND order_date <= ?_tend | STATS bucket_0_0 = COUNT(*) BY order_date = BUCKET(order_date, 30 minutes) - | SORT order_date ASC` + | SORT order_date ASC`, + }) ); }); @@ -239,14 +251,17 @@ describe('to_esql', () => { new Date() ); - expect(esql?.esql).toEqual( - `FROM myIndexPattern + expect(esql).toEqual( + expect.objectContaining({ + success: true, + esql: `FROM myIndexPattern | STATS bucket_0_0 = COUNT(*) BY order_date = BUCKET(order_date, 30 minutes) - | SORT order_date ASC` + | SORT order_date ASC`, + }) ); }); - it('should return undefined if timezone is not UTC', () => { + it('should return failure with non_utc_timezone reason if timezone is not UTC', () => { uiSettings.get.mockImplementation((key: string) => { if (key === 'dateFormat:tz') return 'America/Chicago'; return defaultUiSettingsGet(key); @@ -286,7 +301,10 @@ describe('to_esql', () => { new Date() ); - expect(esql).toEqual(undefined); + expect(esql).toEqual({ + success: false, + reason: 'non_utc_timezone', + }); }); it('should work with iana timezones that fall under UTC+0', () => { @@ -330,11 +348,14 @@ describe('to_esql', () => { new Date() ); - expect(esql?.esql).toEqual( - `FROM myIndexPattern + expect(esql).toEqual( + expect.objectContaining({ + success: true, + esql: `FROM myIndexPattern | WHERE order_date >= ?_tstart AND order_date <= ?_tend | STATS bucket_0_0 = COUNT(*) BY order_date = BUCKET(order_date, 30 minutes) - | SORT order_date ASC` + | SORT order_date ASC`, + }) ); }); @@ -377,11 +398,14 @@ describe('to_esql', () => { new Date() ); - expect(esql?.esql).toEqual( - `FROM myIndexPattern + expect(esql).toEqual( + expect.objectContaining({ + success: true, + esql: `FROM myIndexPattern | WHERE order_date >= ?_tstart AND order_date <= ?_tend | STATS bucket_0_0 = COUNT(*) WHERE KQL("geo.src:\\"US\\"") BY order_date = BUCKET(order_date, 30 minutes) - | SORT order_date ASC` + | SORT order_date ASC`, + }) ); }); }); diff --git a/x-pack/platform/plugins/shared/lens/public/datasources/form_based/to_esql.top_n.test.ts b/x-pack/platform/plugins/shared/lens/public/datasources/form_based/to_esql.top_n.test.ts index 782d69a9f81cb..a0c29b70225b7 100644 --- a/x-pack/platform/plugins/shared/lens/public/datasources/form_based/to_esql.top_n.test.ts +++ b/x-pack/platform/plugins/shared/lens/public/datasources/form_based/to_esql.top_n.test.ts @@ -106,6 +106,9 @@ describe('to_esql top N', () => { mockNowInstant ); - expect(result?.esql).toEqual(undefined); + expect(result).toEqual({ + success: false, + reason: 'terms_not_supported', + }); }); }); diff --git a/x-pack/platform/plugins/shared/lens/public/datasources/form_based/to_esql.ts b/x-pack/platform/plugins/shared/lens/public/datasources/form_based/to_esql.ts index eb0815422d77d..0c80ebb1a6dff 100644 --- a/x-pack/platform/plugins/shared/lens/public/datasources/form_based/to_esql.ts +++ b/x-pack/platform/plugins/shared/lens/public/datasources/form_based/to_esql.ts @@ -25,10 +25,47 @@ import { convertToAbsoluteDateRange } from '../../utils'; import type { OriginalColumn } from '../../../common/types'; import { operationDefinitionMap } from './operations'; import { resolveTimeShift } from './time_shift_utils'; +import type { EsqlConversionFailureReason } from './to_esql_failure_reasons'; // esAggs column ID manipulation functions export const extractAggId = (id: string) => id.split('.')[0].split('-')[2]; +interface EsqlQuerySuccess { + success: true; + esql: string; + partialRows: boolean; + esAggsIdMap: Record; +} + +interface EsqlQueryFailure { + success: false; + reason: EsqlConversionFailureReason; + operationType?: string; +} + +/** + * Result type for getESQLForLayer. + * Either a successful conversion with the ES|QL query, + * or a failure with a specific reason. + */ +export type EsqlQueryResult = EsqlQuerySuccess | EsqlQueryFailure; + +/** + * Type guard to check if the result is a successful ES|QL query. + */ +export const isEsqlQuerySuccess = (result: unknown): result is EsqlQuerySuccess => + result !== null && typeof result === 'object' && 'success' in result && result.success === true; + +/** + * Helper function to create a consistent failure result for ES|QL query generation. + */ +function getEsqlQueryFailedResult( + reason: EsqlConversionFailureReason, + operationType?: string +): EsqlQueryFailure { + return operationType ? { success: false, reason, operationType } : { success: false, reason }; +} + // Need a more complex logic for decimals percentiles export function getESQLForLayer( @@ -38,23 +75,28 @@ export function getESQLForLayer( uiSettings: IUiSettingsClient, dateRange: DateRange, nowInstant: Date -) { +): EsqlQueryResult { // esql mode variables const partialRows = true; const timeZone = getUserTimeZone((key) => uiSettings.get(key), true); const utcOffset = moment.tz(timeZone).utcOffset() / 60; - if (utcOffset !== 0) return; - - if ( - Object.values(layer.columns).find( - (col) => - col.operationType === 'formula' || - col.timeShift || - ('sourceField' in col && indexPattern.getFieldByName(col.sourceField)?.runtime) - ) - ) - return; + if (utcOffset !== 0) { + return getEsqlQueryFailedResult('non_utc_timezone'); + } + + // Check for unsupported column features + for (const col of Object.values(layer.columns)) { + if (col.operationType === 'formula') { + return getEsqlQueryFailedResult('formula_not_supported'); + } + if (col.timeShift) { + return getEsqlQueryFailedResult('time_shift_not_supported'); + } + if ('sourceField' in col && indexPattern.getFieldByName(col.sourceField)?.runtime) { + return getEsqlQueryFailedResult('runtime_field_not_supported'); + } + } // indexPattern.title is the actual es pattern let esqlCompose = from(indexPattern.title); @@ -85,216 +127,263 @@ export function getESQLForLayer( // Collect all params from metrics and buckets const allParamObjects: Array> = []; - const metrics = metricEsAggsEntries.map(([colId, col], index) => { - const def = operationDefinitionMap[col.operationType]; + // Process metrics + const metricsResult: Array = metricEsAggsEntries.map( + ([colId, col], index) => { + const def = operationDefinitionMap[col.operationType]; - if (!def.toESQL) return undefined; - - const aggId = String(index); - const wrapInFilter = Boolean(def.filterable && col.filter?.query); - const wrapInTimeFilter = - def.canReduceTimeRange && - !hasDateHistogram && - col.reducedTimeRange && - indexPattern.timeFieldName; + // Check for specific unsupported operations before general toESQL check + if (col.operationType === 'formula') { + return getEsqlQueryFailedResult('formula_not_supported'); + } - if (wrapInTimeFilter) { - return undefined; - } + if (!def.toESQL) { + return getEsqlQueryFailedResult('function_not_supported', col.operationType); + } - const esAggsId = window.ELASTIC_LENS_DELAY_SECONDS - ? `bucket_${index + 1}_${aggId}` - : `bucket_${index}_${aggId}`; + const aggId = String(index); + const wrapInFilter = Boolean(def.filterable && col.filter?.query); + const wrapInTimeFilter = + def.canReduceTimeRange && + !hasDateHistogram && + col.reducedTimeRange && + indexPattern.timeFieldName; + + if (wrapInTimeFilter) { + return getEsqlQueryFailedResult('reduced_time_range_not_supported'); + } - const format = - operationDefinitionMap[col.operationType].getSerializedFormat?.( - col, - col, + const esAggsId = window.ELASTIC_LENS_DELAY_SECONDS + ? `bucket_${index + 1}_${aggId}` + : `bucket_${index}_${aggId}`; + + const format = + operationDefinitionMap[col.operationType].getSerializedFormat?.( + col, + col, + indexPattern, + uiSettings, + dateRange + ) ?? + ('sourceField' in col + ? col.sourceField === '___records___' + ? { id: 'number' } + : indexPattern.getFormatterForField(col.sourceField) + : undefined); + + esAggsIdMap[esAggsId] = [ + { + ...col, + id: colId, + format: format as unknown as ValueFormatConfig, + interval: undefined as never, + label: col.customLabel + ? col.label + : operationDefinitionMap[col.operationType].getDefaultLabel( + col, + layer.columns, + indexPattern, + uiSettings, + dateRange + ), + }, + ]; + + const rawResult = def.toESQL( + { + ...col, + timeShift: resolveTimeShift( + col.timeShift, + absDateRange, + histogramBarsTarget, + hasDateHistogram + ), + }, + wrapInFilter || wrapInTimeFilter ? `${aggId}-metric` : aggId, indexPattern, + layer, uiSettings, dateRange - ) ?? - ('sourceField' in col - ? col.sourceField === '___records___' - ? { id: 'number' } - : indexPattern.getFormatterForField(col.sourceField) - : undefined); - - esAggsIdMap[esAggsId] = [ - { - ...col, - id: colId, - format: format as unknown as ValueFormatConfig, - interval: undefined as never, - label: col.customLabel - ? col.label - : operationDefinitionMap[col.operationType].getDefaultLabel( - col, - layer.columns, - indexPattern, - uiSettings, - dateRange - ), - }, - ]; - - const rawResult = def.toESQL( - { - ...col, - timeShift: resolveTimeShift( - col.timeShift, - absDateRange, - histogramBarsTarget, - hasDateHistogram - ), - }, - wrapInFilter || wrapInTimeFilter ? `${aggId}-metric` : aggId, - indexPattern, - layer, - uiSettings, - dateRange - ); + ); - if (!rawResult) return undefined; + if (!rawResult) { + return getEsqlQueryFailedResult('function_not_supported', col.operationType); + } - if (rawResult.params) { - allParamObjects.push(rawResult.params); - } + if (rawResult.params) { + allParamObjects.push(rawResult.params); + } - let metricESQL = `${esAggsId} = ${rawResult.template}`; + let metricESQL = `${esAggsId} = ${rawResult.template}`; - if (wrapInFilter) { - if (col.filter?.language === 'kuery') { - metricESQL += ` WHERE KQL("""${col.filter.query.replace(/"""/g, '')}""")`; - } else if (col.filter?.language === 'lucene') { - metricESQL += ` WHERE QSTR("""${col.filter.query.replace(/"""/g, '')}""")`; - } else { - return; + if (wrapInFilter) { + if (col.filter?.language === 'kuery') { + metricESQL += ` WHERE KQL("""${col.filter.query.replace(/"""/g, '')}""")`; + } else if (col.filter?.language === 'lucene') { + metricESQL += ` WHERE QSTR("""${col.filter.query.replace(/"""/g, '')}""")`; + } else { + return getEsqlQueryFailedResult('unknown'); + } } + + return metricESQL; } + ); - return metricESQL; - }); - - if (metrics.some((m) => !m)) return; - - const buckets = bucketEsAggsEntries.map(([colId, col], index) => { - const def = operationDefinitionMap[col.operationType]; - - if (!def.toESQL) return undefined; - - const aggId = String(index); - const wrapInFilter = Boolean(def.filterable && col.filter?.query); - const wrapInTimeFilter = - def.canReduceTimeRange && - !hasDateHistogram && - col.reducedTimeRange && - indexPattern.timeFieldName; - - let esAggsId = window.ELASTIC_LENS_DELAY_SECONDS - ? `col_${index}-${aggId}` - : `col_${index}_${aggId}`; - - let interval: number | undefined; - if (isColumnOfType('date_histogram', col)) { - const dateHistogramColumn = col as DateHistogramIndexPatternColumn; - const calcAutoInterval = getCalculateAutoTimeExpression((key) => uiSettings.get(key)); - - const cleanInterval = (i: string) => { - switch (i) { - case 'd': - return '1d'; - case 'h': - return '1h'; - case 'm': - return '1m'; - case 's': - return '1s'; - case 'ms': - return '1ms'; - default: - return i; + // Check for metric failures + const metricFailure = metricsResult.find( + (m): m is EsqlQueryFailure => typeof m === 'object' && 'success' in m && !m.success + ); + if (metricFailure) { + return metricFailure; + } + + const metrics = metricsResult as string[]; + + // Process buckets + const bucketsResult: Array = bucketEsAggsEntries.map( + ([colId, col], index) => { + const def = operationDefinitionMap[col.operationType]; + + // Check for specific unsupported operations before general toESQL check + if (col.operationType === 'terms') { + return getEsqlQueryFailedResult('terms_not_supported'); + } + + if (!def.toESQL) { + return getEsqlQueryFailedResult('function_not_supported', col.operationType); + } + + const aggId = String(index); + const wrapInFilter = Boolean(def.filterable && col.filter?.query); + const wrapInTimeFilter = + def.canReduceTimeRange && + !hasDateHistogram && + col.reducedTimeRange && + indexPattern.timeFieldName; + + let esAggsId = window.ELASTIC_LENS_DELAY_SECONDS + ? `col_${index}-${aggId}` + : `col_${index}_${aggId}`; + + let interval: number | undefined; + if (isColumnOfType('date_histogram', col)) { + const dateHistogramColumn = col as DateHistogramIndexPatternColumn; + const calcAutoInterval = getCalculateAutoTimeExpression((key) => uiSettings.get(key)); + + const cleanInterval = (i: string) => { + switch (i) { + case 'd': + return '1d'; + case 'h': + return '1h'; + case 'm': + return '1m'; + case 's': + return '1s'; + case 'ms': + return '1ms'; + default: + return i; + } + }; + esAggsId = dateHistogramColumn.sourceField; + const kibanaInterval = + dateHistogramColumn.params?.interval === 'auto' + ? calcAutoInterval({ from: dateRange.fromDate, to: dateRange.toDate }) || '1h' + : dateHistogramColumn.params?.interval || '1h'; + const esInterval = convertIntervalToEsInterval(cleanInterval(kibanaInterval)); + interval = moment.duration(esInterval.value, esInterval.unit).as('ms'); + } + + const format = + operationDefinitionMap[col.operationType].getSerializedFormat?.( + col, + col, + indexPattern, + uiSettings, + dateRange + ) ?? + ('sourceField' in col ? indexPattern.getFormatterForField(col.sourceField) : undefined); + + esAggsIdMap[esAggsId] = [ + { + ...col, + id: colId, + format: format as unknown as ValueFormatConfig, + interval: interval as never, + ...('sourceField' in col ? { sourceField: col.sourceField! } : {}), + label: col.customLabel + ? col.label + : operationDefinitionMap[col.operationType].getDefaultLabel( + col, + layer.columns, + indexPattern, + uiSettings, + dateRange + ), + }, + ]; + + if (isColumnOfType('date_histogram', col)) { + const column = col; + if ( + column.params?.dropPartials && + // set to false when detached from time picker + (indexPattern.timeFieldName === indexPattern.getFieldByName(column.sourceField)?.name || + !column.params?.ignoreTimeRange) + ) { + return getEsqlQueryFailedResult('drop_partials_not_supported'); } - }; - esAggsId = dateHistogramColumn.sourceField; - const kibanaInterval = - dateHistogramColumn.params?.interval === 'auto' - ? calcAutoInterval({ from: dateRange.fromDate, to: dateRange.toDate }) || '1h' - : dateHistogramColumn.params?.interval || '1h'; - const esInterval = convertIntervalToEsInterval(cleanInterval(kibanaInterval)); - interval = moment.duration(esInterval.value, esInterval.unit).as('ms'); - } - const format = - operationDefinitionMap[col.operationType].getSerializedFormat?.( - col, - col, + if (column.params?.includeEmptyRows) { + return getEsqlQueryFailedResult('include_empty_rows_not_supported'); + } + } + + const rawResult = def.toESQL( + { + ...col, + timeShift: resolveTimeShift( + col.timeShift, + absDateRange, + histogramBarsTarget, + hasDateHistogram + ), + }, + wrapInFilter || wrapInTimeFilter ? `${aggId}-metric` : aggId, indexPattern, + layer, uiSettings, dateRange - ) ?? ('sourceField' in col ? indexPattern.getFormatterForField(col.sourceField) : undefined); - - esAggsIdMap[esAggsId] = [ - { - ...col, - id: colId, - format: format as unknown as ValueFormatConfig, - interval: interval as never, - ...('sourceField' in col ? { sourceField: col.sourceField! } : {}), - label: col.customLabel - ? col.label - : operationDefinitionMap[col.operationType].getDefaultLabel( - col, - layer.columns, - indexPattern, - uiSettings, - dateRange - ), - }, - ]; - - if (isColumnOfType('date_histogram', col)) { - const column = col; - if ( - column.params?.dropPartials && - // set to false when detached from time picker - (indexPattern.timeFieldName === indexPattern.getFieldByName(column.sourceField)?.name || - !column.params?.ignoreTimeRange) - ) { - return undefined; - } - } + ); - const rawResult = def.toESQL( - { - ...col, - timeShift: resolveTimeShift( - col.timeShift, - absDateRange, - histogramBarsTarget, - hasDateHistogram - ), - }, - wrapInFilter || wrapInTimeFilter ? `${aggId}-metric` : aggId, - indexPattern, - layer, - uiSettings, - dateRange - ); + if (!rawResult) { + return getEsqlQueryFailedResult('function_not_supported', col.operationType); + } - if (!rawResult) return undefined; + if (rawResult.params) { + allParamObjects.push(rawResult.params); + } - if (rawResult.params) { - allParamObjects.push(rawResult.params); + return `${esAggsId} = ${rawResult.template}`; } + ); - return `${esAggsId} = ${rawResult.template}`; - }); + // Check for bucket failures + const bucketFailure = bucketsResult.find( + (b): b is EsqlQueryFailure => typeof b === 'object' && 'success' in b && !b.success + ); + if (bucketFailure) { + return bucketFailure; + } - if (buckets.some((m) => !m)) return; + const buckets = bucketsResult as string[]; if (buckets.length > 0) { - if (buckets.some((b) => !b || b.includes('undefined'))) return; + if (buckets.some((b) => !b || b.includes('undefined'))) { + return getEsqlQueryFailedResult('unknown'); + } if (metrics.length > 0) { const statsBody = `${metrics.join(', ')} BY ${buckets.join(', ')}`; @@ -330,11 +419,12 @@ export function getESQLForLayer( try { return { + success: true, esql: esqlCompose.toString(), partialRows, esAggsIdMap, }; } catch (e) { - return; + return getEsqlQueryFailedResult('unknown'); } } diff --git a/x-pack/platform/plugins/shared/lens/public/datasources/form_based/to_esql_failure_reasons.ts b/x-pack/platform/plugins/shared/lens/public/datasources/form_based/to_esql_failure_reasons.ts new file mode 100644 index 0000000000000..0db2665bfb6a2 --- /dev/null +++ b/x-pack/platform/plugins/shared/lens/public/datasources/form_based/to_esql_failure_reasons.ts @@ -0,0 +1,116 @@ +/* + * 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'; + +/** + * Specific reasons why ES|QL conversion failed. + * These are used to provide granular user feedback. + */ +export type EsqlConversionFailureReason = + | 'multi_layer_not_supported' + | 'trend_line_not_supported' + | 'non_utc_timezone' + | 'formula_not_supported' + | 'time_shift_not_supported' + | 'runtime_field_not_supported' + | 'reduced_time_range_not_supported' + | 'function_not_supported' + | 'drop_partials_not_supported' + | 'include_empty_rows_not_supported' + | 'terms_not_supported' + | 'unsupported_settings' + | 'unknown'; + +export const esqlConversionFailureReasonMessages: Record = { + multi_layer_not_supported: i18n.translate( + 'xpack.lens.config.cannotConvertToEsqlMultiLayerTooltip', + { + defaultMessage: + 'Cannot convert to ES|QL: Multi-layer visualizations will be supported in an upcoming update.', + } + ), + trend_line_not_supported: i18n.translate( + 'xpack.lens.config.cannotConvertToEsqlTrendLineTooltip', + { + defaultMessage: + 'Cannot convert to ES|QL: Metric visualizations with trend lines will be supported in an upcoming update.', + } + ), + non_utc_timezone: i18n.translate('xpack.lens.config.cannotConvertToEsqlNonUtcTimezoneTooltip', { + defaultMessage: + 'Cannot convert to ES|QL: Non-UTC timezones will be supported in an upcoming update.', + }), + formula_not_supported: i18n.translate('xpack.lens.config.cannotConvertToEsqlFormulaTooltip', { + defaultMessage: + 'Cannot convert to ES|QL: Formula operations will be supported in an upcoming update.', + }), + time_shift_not_supported: i18n.translate( + 'xpack.lens.config.cannotConvertToEsqlTimeShiftTooltip', + { + defaultMessage: + 'Cannot convert to ES|QL: Time shift will be supported in an upcoming update.', + } + ), + runtime_field_not_supported: i18n.translate( + 'xpack.lens.config.cannotConvertToEsqlRuntimeFieldTooltip', + { + defaultMessage: + 'Cannot convert to ES|QL: Runtime fields will be supported in an upcoming update.', + } + ), + reduced_time_range_not_supported: i18n.translate( + 'xpack.lens.config.cannotConvertToEsqlReducedTimeRangeTooltip', + { + defaultMessage: + 'Cannot convert to ES|QL: Reduced time range will be supported in an upcoming update.', + } + ), + function_not_supported: i18n.translate('xpack.lens.config.cannotConvertToEsqlOperationTooltip', { + defaultMessage: + 'Cannot convert to ES|QL: Support for one or more functions used will be coming in an upcoming update.', + }), + drop_partials_not_supported: i18n.translate( + 'xpack.lens.config.cannotConvertToEsqlDropPartialsTooltip', + { + defaultMessage: + 'Cannot convert to ES|QL: "Drop partial buckets" will be supported in an upcoming update.', + } + ), + include_empty_rows_not_supported: i18n.translate( + 'xpack.lens.config.cannotConvertToEsqlIncludeEmptyRowsTooltip', + { + defaultMessage: + 'Cannot convert to ES|QL: "Include empty rows" will be supported in an upcoming update.', + } + ), + terms_not_supported: i18n.translate('xpack.lens.config.cannotConvertToEsqlTermsTooltip', { + defaultMessage: + 'Cannot convert to ES|QL: Top values (terms) aggregation will be supported in an upcoming update.', + }), + unsupported_settings: i18n.translate( + 'xpack.lens.config.cannotConvertToEsqlUnsupportedSettingsTooltip', + { + defaultMessage: + 'Cannot convert to ES|QL: Some settings used will be supported in an upcoming update.', + } + ), + unknown: i18n.translate('xpack.lens.config.cannotConvertToEsqlUnknownTooltip', { + defaultMessage: + 'Cannot convert to ES|QL: This visualization will be supported in an upcoming update.', + }), +}; + +export const getFailureTooltip = (reason: EsqlConversionFailureReason | undefined): string => { + if (!reason) { + return esqlConversionFailureReasonMessages.unknown; + } + return ( + esqlConversionFailureReasonMessages[reason] ?? + esqlConversionFailureReasonMessages.unsupported_settings + ); +}; diff --git a/x-pack/platform/plugins/shared/lens/public/datasources/form_based/to_expression.ts b/x-pack/platform/plugins/shared/lens/public/datasources/form_based/to_expression.ts index e8a54bf7ef9f6..14dc83f9b57df 100644 --- a/x-pack/platform/plugins/shared/lens/public/datasources/form_based/to_expression.ts +++ b/x-pack/platform/plugins/shared/lens/public/datasources/form_based/to_expression.ts @@ -33,7 +33,7 @@ import type { IndexPatternMap, RangeIndexPatternColumn, } from '@kbn/lens-common'; -import { getESQLForLayer } from './to_esql'; +import { getESQLForLayer, isEsqlQuerySuccess } from './to_esql'; import { convertToAbsoluteDateRange } from '../../utils'; import { operationDefinitionMap } from './operations'; import { isColumnFormatted, isColumnOfType } from './operations/definitions/helpers'; @@ -181,8 +181,9 @@ function getExpressionForLayer( const esqlLayer = canUseESQL && getESQLForLayer(esAggEntries, layer, indexPattern, uiSettings, dateRange, nowInstant); + const isFormBasedEsqlMode = canUseESQL && isEsqlQuerySuccess(esqlLayer); - if (!esqlLayer) { + if (!isFormBasedEsqlMode) { esAggEntries.forEach(([colId, col], index) => { const def = operationDefinitionMap[col.operationType]; if (def.input !== 'fullReference' && def.input !== 'managedReference') { @@ -361,7 +362,10 @@ function getExpressionForLayer( esAggsIdMap = updatedEsAggsIdMap; } else { - esAggsIdMap = esqlLayer.esAggsIdMap; + // The esAggsIdMap from getESQLForLayer uses the common OriginalColumn type, + // but the local OriginalColumn type includes more properties. The runtime + // objects have all the necessary properties via the spread operator in getESQLForLayer. + esAggsIdMap = esqlLayer.esAggsIdMap as unknown as Record; } const columnsWithFormatters = columnEntries.filter( @@ -481,7 +485,7 @@ function getExpressionForLayer( ) .filter((field): field is string => Boolean(field)); - const dataAST = esqlLayer + const dataAST = isFormBasedEsqlMode ? buildExpressionFunction('esql', { query: esqlLayer.esql, timeField: allDateHistogramFields[0], @@ -513,7 +517,7 @@ function getExpressionForLayer( function: 'lens_map_to_columns', arguments: { idMap: [JSON.stringify(esAggsIdMap)], - isTextBased: [!!esqlLayer], + isTextBased: [isFormBasedEsqlMode], }, }, ...expressions,