diff --git a/src/platform/packages/shared/kbn-discover-utils/src/types.ts b/src/platform/packages/shared/kbn-discover-utils/src/types.ts index 8b5f47893c3f6..ee1ed895d7cb8 100644 --- a/src/platform/packages/shared/kbn-discover-utils/src/types.ts +++ b/src/platform/packages/shared/kbn-discover-utils/src/types.ts @@ -8,6 +8,7 @@ */ import type { SearchHit } from '@elastic/elasticsearch/lib/api/types'; +import type { ESQLColumnsWithHighlights } from '@kbn/esql-utils'; import type { DatatableColumnMeta } from '@kbn/expressions-plugin/common'; export type { IgnoredReason, ShouldShowFieldInTableHandler } from './utils'; @@ -26,6 +27,11 @@ export interface EsHitRecord extends Omit { ]); }); + it('orders inline highlights first', () => { + const highlightHit = buildDataTableRecord( + { + ...hit, + inline_highlights: { message: { preTag: '', postTag: '' } }, + }, + dataViewMock + ); + const formatted = formatHitReact( + highlightHit, + dataViewMock, + (fieldName) => ['_index', 'message', 'extension', 'object.value'].includes(fieldName), + 220, + fieldFormatsMock, + undefined + ); + expect(formatted.map(([fieldName]) => fieldName)).toEqual([ + 'message', + 'extension', + 'object.value', + '_index', + '_score', + ]); + }); + it('only limits count of pairs based on advanced setting', () => { const formatted = formatHitReact( row, diff --git a/src/platform/packages/shared/kbn-discover-utils/src/utils/format_hit.ts b/src/platform/packages/shared/kbn-discover-utils/src/utils/format_hit.ts index 5ec110e5bc421..6a63df55b0e20 100644 --- a/src/platform/packages/shared/kbn-discover-utils/src/utils/format_hit.ts +++ b/src/platform/packages/shared/kbn-discover-utils/src/utils/format_hit.ts @@ -58,7 +58,7 @@ export function formatHitReact( return cached.formattedHit; } - const highlights = hit.raw.highlight ?? {}; + const highlights = hit.raw.highlight ?? hit.raw.inline_highlights ?? {}; const flattened = hit.flattened; const renderedPairs: PartialHitReactPair[] = []; const otherPairs: PartialHitReactPair[] = []; @@ -114,7 +114,6 @@ export function formatHitReact( fieldName: key, columnMeta: columnsMeta?.[key], }); - pair[1] = formatFieldValueReact({ value: flattened[key], hit: hit.raw, diff --git a/src/platform/packages/shared/kbn-esql-utils/index.ts b/src/platform/packages/shared/kbn-esql-utils/index.ts index 6a25580802ce7..3f77980cf0ed6 100644 --- a/src/platform/packages/shared/kbn-esql-utils/index.ts +++ b/src/platform/packages/shared/kbn-esql-utils/index.ts @@ -79,6 +79,7 @@ export { formatEsqlLiteral, isComputedColumn, getQuerySummary, + getColumnsWithHighlights, buildRenameSourceFieldMap, getEsqlControls, getAllEsqlControls, @@ -86,6 +87,8 @@ export { convertQueryToESQLExpression, injectWhereClauseAfterSourceCommand, type ESQLStatsQueryMeta, + type ESQLColumnsWithHighlights, + type ESQLHighlightTags, } from './src'; export { ENABLE_ESQL, GROUP_NOT_SET_VALUE } from './constants'; diff --git a/src/platform/packages/shared/kbn-esql-utils/src/index.ts b/src/platform/packages/shared/kbn-esql-utils/src/index.ts index 08d7a4ea40f7a..b954bcc3c1d3c 100644 --- a/src/platform/packages/shared/kbn-esql-utils/src/index.ts +++ b/src/platform/packages/shared/kbn-esql-utils/src/index.ts @@ -83,6 +83,11 @@ export { } from './utils/cascaded_documents_helpers/utils'; export { getProjectRoutingFromEsqlQuery } from './utils/set_instructions_helpers'; export { isComputedColumn, getQuerySummary } from './utils/get_query_summary'; +export { + getColumnsWithHighlights, + type ESQLColumnsWithHighlights, + type ESQLHighlightTags, +} from './utils/get_columns_with_highlights'; export { buildRenameSourceFieldMap } from './utils/build_rename_source_field_map'; export { getAllEsqlControls, getEsqlControls } from './utils/get_esql_controls'; export { convertFiltersToESQLExpression } from './utils/convert_filters_to_esql'; diff --git a/src/platform/packages/shared/kbn-esql-utils/src/utils/get_columns_with_highlights.test.ts b/src/platform/packages/shared/kbn-esql-utils/src/utils/get_columns_with_highlights.test.ts new file mode 100644 index 0000000000000..5956c0e77a605 --- /dev/null +++ b/src/platform/packages/shared/kbn-esql-utils/src/utils/get_columns_with_highlights.test.ts @@ -0,0 +1,112 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { + DEFAULT_HIGHLIGHT_POST_TAG, + DEFAULT_HIGHLIGHT_PRE_TAG, + getColumnsWithHighlights, +} from './get_columns_with_highlights'; + +describe('getColumnsWithHighlights', () => { + it('returns column with default em tags when highlight is enabled', () => { + const query = + 'FROM books | EVAL snippets = TOP_SNIPPETS(description, "Tolkien", { "highlight": true })'; + expect(getColumnsWithHighlights(query)).toEqual({ + snippets: { + preTag: DEFAULT_HIGHLIGHT_PRE_TAG, + postTag: DEFAULT_HIGHLIGHT_POST_TAG, + }, + }); + }); + + it('returns custom pre_tag and post_tag from TOP_SNIPPETS options', () => { + const query = + 'FROM books | EVAL snippets = TOP_SNIPPETS(description, "Tolkien", { "highlight": true, "pre_tag": "", "post_tag": "" })'; + expect(getColumnsWithHighlights(query)).toEqual({ + snippets: { + preTag: '', + postTag: '', + }, + }); + }); + + it('ignores TOP_SNIPPETS without highlight option', () => { + const query = 'FROM books | EVAL snippets = TOP_SNIPPETS(description, "Tolkien")'; + expect(getColumnsWithHighlights(query)).toEqual({}); + }); + + it('returns multiple columns with their respective tags', () => { + const query = + 'FROM books | EVAL a = TOP_SNIPPETS(description, "one", { "highlight": true }) | EVAL b = TOP_SNIPPETS(title, "two", { "highlight": true, "pre_tag": "", "post_tag": "" })'; + expect(getColumnsWithHighlights(query)).toEqual({ + a: { + preTag: DEFAULT_HIGHLIGHT_PRE_TAG, + postTag: DEFAULT_HIGHLIGHT_POST_TAG, + }, + b: { + preTag: '', + postTag: '', + }, + }); + }); + + it('handles EVAL unamed columns scenarios', () => { + const query = 'FROM books | EVAL TOP_SNIPPETS(description, "one", { "highlight": true })'; + expect(getColumnsWithHighlights(query)).toEqual({ + 'TOP_SNIPPETS(description, "one", { "highlight": true })': { + preTag: DEFAULT_HIGHLIGHT_PRE_TAG, + postTag: DEFAULT_HIGHLIGHT_POST_TAG, + }, + }); + }); + + it('handles STATS user defined columns', () => { + const query = + 'FROM books | STATS count(*) BY col0 = TOP_SNIPPETS(description, "one", { "highlight": true })'; + expect(getColumnsWithHighlights(query)).toEqual({ + col0: { + preTag: DEFAULT_HIGHLIGHT_PRE_TAG, + postTag: DEFAULT_HIGHLIGHT_POST_TAG, + }, + }); + }); + + it('handles STATS unamed user defined columns', () => { + const query = + 'FROM books | STATS count(*) BY TOP_SNIPPETS(description, "one", { "highlight": true })'; + expect(getColumnsWithHighlights(query)).toEqual({ + 'TOP_SNIPPETS(description, "one", { "highlight": true })': { + preTag: DEFAULT_HIGHLIGHT_PRE_TAG, + postTag: DEFAULT_HIGHLIGHT_POST_TAG, + }, + }); + }); + + it('applies RENAME to resolved highlight column names', () => { + const query = + 'FROM books | EVAL col0 = TOP_SNIPPETS(description, "one", { "highlight": true }) | RENAME col0 AS renamed'; + expect(getColumnsWithHighlights(query)).toEqual({ + renamed: { + preTag: DEFAULT_HIGHLIGHT_PRE_TAG, + postTag: DEFAULT_HIGHLIGHT_POST_TAG, + }, + }); + }); + + it('can handle columns defined within quotes', () => { + const query = + 'FROM books | EVAL `col0` = TOP_SNIPPETS(description, "one", { "highlight": true })'; + expect(getColumnsWithHighlights(query)).toEqual({ + col0: { + preTag: DEFAULT_HIGHLIGHT_PRE_TAG, + postTag: DEFAULT_HIGHLIGHT_POST_TAG, + }, + }); + }); +}); diff --git a/src/platform/packages/shared/kbn-esql-utils/src/utils/get_columns_with_highlights.ts b/src/platform/packages/shared/kbn-esql-utils/src/utils/get_columns_with_highlights.ts new file mode 100644 index 0000000000000..7a8a14103c8f5 --- /dev/null +++ b/src/platform/packages/shared/kbn-esql-utils/src/utils/get_columns_with_highlights.ts @@ -0,0 +1,160 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { + isAssignment, + isBooleanLiteral, + isColumn, + isMap, + isStringLiteral, + LeafPrinter, + Parser, + Walker, +} from '@elastic/esql'; + +import type { ESQLAstQueryExpression, ESQLFunction, ESQLMap } from '@elastic/esql/types'; +import { replaceColumnNamesIfRenamed } from './query_parsing_helpers'; + +export const DEFAULT_HIGHLIGHT_PRE_TAG = ''; +export const DEFAULT_HIGHLIGHT_POST_TAG = ''; + +const HIGHLIGHT_OPTION_NAME = 'highlight'; +const PRE_TAG_OPTION_NAME = 'pre_tag'; +const POST_TAG_OPTION_NAME = 'post_tag'; + +/** + * ES|QL functions that can produce highlight markup in output columns when + * called with `{ "highlight": true }`. + */ +const FUNCTIONS_WITH_HIGHLIGHT_SUPPORT = ['top_snippets']; + +export interface ESQLHighlightTags { + preTag: string; + postTag: string; +} + +export type ESQLColumnsWithHighlights = Record; + +/** + * Returns columns built using a highlighting algorithm, + * including the opening and closing markup tags configured for each column. + * + * Example: + * ``` + * FROM books + * | EVAL snippets = TOP_SNIPPETS(description, "Tolkien", { "highlight": true }) + * | EVAL titles = TOP_SNIPPETS(title, "Tolkien", { "highlight": true, "pre_tag": "", "post_tag": "" }) + * ``` + * Will return the following map: + * ``` + * { + * snippets: { + * preTag: '', + * postTag: '', + * }, + * titles: { + * preTag: '', + * postTag: '', + * }, + * } + */ +export function getColumnsWithHighlights(query: string): ESQLColumnsWithHighlights { + const columnsWithHighlights: ESQLColumnsWithHighlights = {}; + const { root } = Parser.parse(query); + + const highlightFunctionsCandidates = Walker.findAll( + root, + (node) => node.type === 'function' && FUNCTIONS_WITH_HIGHLIGHT_SUPPORT.includes(node.name) + ) as ESQLFunction[]; + + for (const fn of highlightFunctionsCandidates) { + const optionsMap = fn.args.find(isMap); + + if (!optionsMap || !isHighlightEnabled(optionsMap)) { + continue; + } + + const columnName = getHighlightedColumnName(root, fn, query); + if (!columnName) { + continue; + } + + const preTag = + getHighlightTagName(optionsMap, PRE_TAG_OPTION_NAME) ?? DEFAULT_HIGHLIGHT_PRE_TAG; + const postTag = + getHighlightTagName(optionsMap, POST_TAG_OPTION_NAME) ?? DEFAULT_HIGHLIGHT_POST_TAG; + + // Check if the column name has been renamed in the query + const [resolvedColumnName] = replaceColumnNamesIfRenamed(root, [columnName]); + + columnsWithHighlights[resolvedColumnName] = { + preTag, + postTag, + }; + } + + return columnsWithHighlights; +} + +/** + * Given a map of options, returns true if the `highlight` option is set to `true`. + */ +const isHighlightEnabled = (optionsMap: ESQLMap): boolean => { + const highlightEntry = optionsMap.entries.find( + (entry) => isStringLiteral(entry.key) && entry.key.valueUnquoted === HIGHLIGHT_OPTION_NAME + ); + if (!highlightEntry?.value) { + return false; + } + + return ( + isBooleanLiteral(highlightEntry.value) && highlightEntry.value.value.toLowerCase() === 'true' + ); +}; + +/** + * Returns the tag name defined in the map options if it exists. + */ +const getHighlightTagName = (optionsMap: ESQLMap, optionName: string): string | undefined => { + const tagEntry = optionsMap.entries.find( + (entry) => isStringLiteral(entry.key) && entry.key.valueUnquoted === optionName + ); + + if (!tagEntry?.value) { + return undefined; + } + if (!isStringLiteral(tagEntry.value)) { + return undefined; + } + return tagEntry.value.valueUnquoted; +}; + +/** + * Returns the name of the column that was created using the highlight function. + * + * This function has an heuristic part, some combination of function could remove the highlighting tokens from the result. + * But it assumes that if the user used highlight:true, it's not interested in removing them. + * Doing a 100% accurate check would involve knowing the semantics of every invoked function. + * In the worst case of having a false positive, a value without highlighting tags will run through the highlighitng code. + */ +const getHighlightedColumnName = ( + root: ESQLAstQueryExpression, + highlightFunction: ESQLFunction, + query: string +): string | undefined => { + // Created using an assignment | EVAL col = TOP_SNIPPETS( ... + for (const parent of Walker.parents(root, highlightFunction)) { + if (isAssignment(parent) && isColumn(parent.args[0])) { + return LeafPrinter.column(parent.args[0]); + } + } + + // Created using an expression text | EVAL TOP_SNIPPETS( ... or STATS count(*) BY TOP_SNIPPETS( ... + return query.substring(highlightFunction.location.min, highlightFunction.location.max + 1); +}; diff --git a/src/platform/packages/shared/kbn-esql-utils/src/utils/query_parsing_helpers.test.ts b/src/platform/packages/shared/kbn-esql-utils/src/utils/query_parsing_helpers.test.ts index 66ac5347a1b61..acc0d623e51dc 100644 --- a/src/platform/packages/shared/kbn-esql-utils/src/utils/query_parsing_helpers.test.ts +++ b/src/platform/packages/shared/kbn-esql-utils/src/utils/query_parsing_helpers.test.ts @@ -25,6 +25,7 @@ import { fixESQLQueryWithVariables, getCategorizeColumns, getArgsFromRenameFunction, + replaceColumnNamesIfRenamed, getCategorizeField, findClosestColumn, getKqlSearchQueries, @@ -922,6 +923,23 @@ describe('esql query helpers', () => { }); }); + describe('replaceColumnNameIfRenamed', () => { + it('returns column names unchanged when there is no RENAME', () => { + const { root } = Parser.parse('FROM index | KEEP col'); + expect(replaceColumnNamesIfRenamed(root, ['col'])).toEqual(['col']); + }); + + it('replaces matching column names using RENAME', () => { + const { root } = Parser.parse('FROM index | RENAME old AS new'); + expect(replaceColumnNamesIfRenamed(root, ['old', 'other'])).toEqual(['new', 'other']); + }); + + it('applies multiple RENAME commands in order', () => { + const { root } = Parser.parse('FROM index | RENAME a AS b | RENAME b AS c'); + expect(replaceColumnNamesIfRenamed(root, ['a'])).toEqual(['c']); + }); + }); + describe('getArgsFromRenameFunction', () => { it('should return the args from an = rename function', () => { const esql = 'FROM index | RENAME renamed = original'; diff --git a/src/platform/packages/shared/kbn-esql-utils/src/utils/query_parsing_helpers.ts b/src/platform/packages/shared/kbn-esql-utils/src/utils/query_parsing_helpers.ts index e1915223a0b56..052dbb402b7fb 100644 --- a/src/platform/packages/shared/kbn-esql-utils/src/utils/query_parsing_helpers.ts +++ b/src/platform/packages/shared/kbn-esql-utils/src/utils/query_parsing_helpers.ts @@ -27,6 +27,7 @@ import type { ESQLInlineCast, ESQLCommandOption, ESQLAstForkCommand, + ESQLAstQueryExpression, } from '@elastic/esql/types'; import { type ESQLControlVariable, ESQLVariableType } from '@kbn/esql-types'; import type { DatatableColumn } from '@kbn/expressions-plugin/common'; @@ -514,24 +515,7 @@ export const getCategorizeColumns = (esql: string): string[] => { } // If there is a rename command, we need to check if the column is renamed - const renameCommand = root.commands.find(({ name }) => name === 'rename'); - if (!renameCommand) { - return columns; - } - const renameFunctions: ESQLFunction[] = []; - walk(renameCommand, { - visitFunction: (node) => renameFunctions.push(node), - }); - - renameFunctions.forEach((renameFunction) => { - const { original, renamed } = getArgsFromRenameFunction(renameFunction); - const oldColumn = original.name; - const newColumn = renamed.name; - if (columns.includes(oldColumn)) { - columns[columns.indexOf(oldColumn)] = newColumn; - } - }); - return columns; + return replaceColumnNamesIfRenamed(root, columns); }; export const getSparklineColumns = (esql: string): string[] => { @@ -584,27 +568,8 @@ export const getSparklineColumns = (esql: string): string[] => { } } - const renameCommands = root.commands.filter(({ name }) => name === 'rename'); - if (renameCommands.length === 0) { - return columns; - } - const renameFunctions: ESQLFunction[] = []; - renameCommands.forEach((renameCommand) => { - walk(renameCommand, { - visitFunction: (node) => renameFunctions.push(node), - }); - }); - - renameFunctions.forEach((renameFunction) => { - const { original, renamed } = getArgsFromRenameFunction(renameFunction); - const oldColumn = original.name; - const newColumn = renamed.name; - if (columns.includes(oldColumn)) { - columns[columns.indexOf(oldColumn)] = newColumn; - } - }); - - return columns; + // If there is a rename command, we need to check if the column is renamed + return replaceColumnNamesIfRenamed(root, columns); }; /** @@ -708,3 +673,36 @@ export const hasTimeseriesInfoCommand = (esql?: string): boolean => { ({ name }) => name === CommandNames.METRICS_INFO || name === CommandNames.TS_INFO ); }; + +/** + * Given an array of column names, it returns a new array with corrected column names + * if any of the columns is renamed in the query. + */ +export const replaceColumnNamesIfRenamed = ( + root: ESQLAstQueryExpression, + columnNames: string[] +): string[] => { + const columns = [...columnNames]; + const renameCommands = root.commands.filter(({ name }) => name === 'rename'); + + if (renameCommands.length === 0) { + return columns; + } + + const renameFunctions: ESQLFunction[] = []; + renameCommands.forEach((renameCommand) => { + walk(renameCommand, { + visitFunction: (node) => renameFunctions.push(node), + }); + }); + + for (const renameFunction of renameFunctions) { + const { original, renamed } = getArgsFromRenameFunction(renameFunction); + const index = columns.indexOf(original.name); + if (index !== -1) { + columns[index] = renamed.name; + } + } + + return columns; +}; diff --git a/src/platform/plugins/shared/discover/public/application/main/data_fetching/fetch_esql.test.ts b/src/platform/plugins/shared/discover/public/application/main/data_fetching/fetch_esql.test.ts index 777a1f61044f4..1055832880f70 100644 --- a/src/platform/plugins/shared/discover/public/application/main/data_fetching/fetch_esql.test.ts +++ b/src/platform/plugins/shared/discover/public/application/main/data_fetching/fetch_esql.test.ts @@ -132,4 +132,35 @@ describe('fetchEsql', () => { expect(result.time).toEqual(absoluteTimeRange); }); + + it('should add inline_highlights to the raw record when inline highlights are present', async () => { + const hits = [ + { _index: 'i', _id: '1', snippets: 'bar' }, + { _index: 'i', _id: '2', snippets: 'baz' }, + ] as unknown as EsHitRecord[]; + const expressionsExecuteSpy = jest.spyOn(discoverServiceMock.expressions, 'execute'); + expressionsExecuteSpy.mockReturnValueOnce({ + cancel: jest.fn(), + getData: jest.fn(() => + of({ + result: { + columns: ['_id', 'snippets'], + rows: hits, + }, + }) + ), + } as unknown as ExecutionContract); + + const result = await fetchEsql({ + ...fetchEsqlMockProps, + query: { esql: 'from * | EVAL snippets = TOP_SNIPPETS(foo, "bar", { "highlight": true })' }, + }); + + expect(result.records[0].raw.inline_highlights).toEqual({ + snippets: { preTag: '', postTag: '' }, + }); + expect(result.records[1].raw.inline_highlights).toEqual({ + snippets: { preTag: '', postTag: '' }, + }); + }); }); diff --git a/src/platform/plugins/shared/discover/public/application/main/data_fetching/fetch_esql.ts b/src/platform/plugins/shared/discover/public/application/main/data_fetching/fetch_esql.ts index 8e0f208fcc699..9c73e1069a2fe 100644 --- a/src/platform/plugins/shared/discover/public/application/main/data_fetching/fetch_esql.ts +++ b/src/platform/plugins/shared/discover/public/application/main/data_fetching/fetch_esql.ts @@ -10,7 +10,14 @@ import { pluck } from 'rxjs'; import { lastValueFrom } from 'rxjs'; import { i18n } from '@kbn/i18n'; -import type { Query, AggregateQuery, Filter, TimeRange, ProjectRouting } from '@kbn/es-query'; +import { + type Query, + type AggregateQuery, + type Filter, + type TimeRange, + type ProjectRouting, + isOfAggregateQueryType, +} from '@kbn/es-query'; import type { Adapters } from '@kbn/inspector-plugin/common'; import type { ESQLControlVariable } from '@kbn/esql-types'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; @@ -21,6 +28,8 @@ import { textBasedQueryStateToAstWithValidation } from '@kbn/data-plugin/common' import { getDocId, type DataTableRecord } from '@kbn/discover-utils'; import type { SearchResponseWarning } from '@kbn/search-response-warnings'; import moment from 'moment'; +import type { ESQLColumnsWithHighlights } from '@kbn/esql-utils'; +import { getColumnsWithHighlights } from '@kbn/esql-utils'; import type { RecordsFetchResponse } from '../../types'; import type { ScopedProfilesManager } from '../../../context_awareness'; @@ -106,10 +115,21 @@ export function fetchEsql({ const responseTime = moment().format('YYYY-MM-DD_HH_mm_ss'); esqlQueryColumns = table?.columns ?? undefined; esqlHeaderWarning = table.warning ?? undefined; + let inlineHighlights: ESQLColumnsWithHighlights | undefined; + if (isOfAggregateQueryType(query)) { + try { + inlineHighlights = getColumnsWithHighlights(query.esql); + } catch (_e) { + inlineHighlights = undefined; + } + } finalData = rows.map((row, idx) => { + const raw = Object.keys(inlineHighlights ?? {}).length + ? { ...row, inline_highlights: inlineHighlights } + : row; const record: DataTableRecord = { id: row._index && row._id ? getDocId(row) : `${idx + 1}@${responseTime}`, - raw: row, + raw, flattened: row, }; diff --git a/src/platform/plugins/shared/field_formats/common/converters/static_lookup.ts b/src/platform/plugins/shared/field_formats/common/converters/static_lookup.ts index bfe0b970113ba..4a22807621544 100644 --- a/src/platform/plugins/shared/field_formats/common/converters/static_lookup.ts +++ b/src/platform/plugins/shared/field_formats/common/converters/static_lookup.ts @@ -118,10 +118,7 @@ export class StaticLookupFormat extends FieldFormat { const formatted = String(result ?? ''); const fieldName = field?.name; - if (fieldName && hit?.highlight?.[fieldName]) { - return getHighlightReact(formatted, hit.highlight[fieldName]); - } - return formatted; + return getHighlightReact(formatted, fieldName, hit); }; } diff --git a/src/platform/plugins/shared/field_formats/common/converters/string.tsx b/src/platform/plugins/shared/field_formats/common/converters/string.tsx index 087a952073c16..02bf6cd6a0f14 100644 --- a/src/platform/plugins/shared/field_formats/common/converters/string.tsx +++ b/src/platform/plugins/shared/field_formats/common/converters/string.tsx @@ -134,11 +134,9 @@ export class StringFormat extends FieldFormat { const missing = this.checkForMissingValueReact(val); if (missing) return missing; + const formatted = this.textConvert(val); const fieldName = field?.name; - if (fieldName && hit?.highlight?.[fieldName]) { - return getHighlightReact(this.textConvert(val), hit.highlight[fieldName]); - } - return this.textConvert(val); + return getHighlightReact(formatted, fieldName, hit); }; } diff --git a/src/platform/plugins/shared/field_formats/common/converters/url.tsx b/src/platform/plugins/shared/field_formats/common/converters/url.tsx index cd49f99b5c4d8..b5070d5585acb 100644 --- a/src/platform/plugins/shared/field_formats/common/converters/url.tsx +++ b/src/platform/plugins/shared/field_formats/common/converters/url.tsx @@ -216,10 +216,7 @@ export class UrlFormat extends FieldFormat { const linkTarget = this.param('openLinkInCurrentTab') ? '_self' : '_blank'; const fieldName = field?.name; - const linkContent = - fieldName && hit?.highlight?.[fieldName] - ? getHighlightReact(label, hit.highlight[fieldName]) - : label; + const linkContent = getHighlightReact(label, fieldName, hit); return ( diff --git a/src/platform/plugins/shared/field_formats/common/field_format.tsx b/src/platform/plugins/shared/field_formats/common/field_format.tsx index 16275e16a18bb..ac354229642b8 100644 --- a/src/platform/plugins/shared/field_formats/common/field_format.tsx +++ b/src/platform/plugins/shared/field_formats/common/field_format.tsx @@ -95,11 +95,7 @@ export abstract class FieldFormat { const formatted = this.convertToText(val, options); const fieldName = options?.field?.name; - const highlights = fieldName ? options?.hit?.highlight?.[fieldName] : undefined; - // getHighlightReact expects a string; guard against edge cases where convert() returns non-string - return highlights && typeof formatted === 'string' - ? getHighlightReact(formatted, highlights) - : formatted; + return getHighlightReact(formatted, fieldName, options?.hit); }; /** diff --git a/src/platform/plugins/shared/field_formats/common/types.ts b/src/platform/plugins/shared/field_formats/common/types.ts index 58af079a731ba..10cbc2edca7fa 100644 --- a/src/platform/plugins/shared/field_formats/common/types.ts +++ b/src/platform/plugins/shared/field_formats/common/types.ts @@ -15,9 +15,19 @@ import type { FieldFormatsRegistry } from './field_formats_registry'; /** * React converter options */ +export interface FieldFormatHighlightTags { + preTag: string; + postTag: string; +} + +export interface ReactContextTypeHit { + highlight?: Record; + inline_highlights?: Record; +} + export interface ReactContextTypeOptions { field?: { name: string }; - hit?: { highlight?: Record }; + hit?: ReactContextTypeHit; skipFormattingInStringifiedJSON?: boolean; } diff --git a/src/platform/plugins/shared/field_formats/common/utils/highlight/highlight_react.test.tsx b/src/platform/plugins/shared/field_formats/common/utils/highlight/highlight_react.test.tsx index 95e0c21205950..c687e8a6081a6 100644 --- a/src/platform/plugins/shared/field_formats/common/utils/highlight/highlight_react.test.tsx +++ b/src/platform/plugins/shared/field_formats/common/utils/highlight/highlight_react.test.tsx @@ -11,6 +11,7 @@ import React from 'react'; import ReactDOM from 'react-dom/server'; import { highlightTags } from './highlight_tags'; import { getHighlightReact } from './highlight_react'; +import type { FieldFormatHighlightTags } from '../../types'; /** Render the ReactNode to a plain HTML string for easy assertion. * " is decoded back to " since both are valid HTML and the difference is @@ -24,85 +25,162 @@ const hl = (word: string) => `${highlightTags.pre}${word}${highlightTags.post}`; const mark = (word: string) => `${word}`; describe('getHighlightReact', () => { - const check = (value: string, highlights: string[] | undefined | null, expected: string) => { - expect(render(getHighlightReact(value, highlights))).toBe(expected); - }; - - test('returns plain string unchanged when highlights are empty', () => { - check('lorem ipsum', undefined, 'lorem ipsum'); - check('lorem ipsum', null, 'lorem ipsum'); - check('lorem ipsum', [], 'lorem ipsum'); + it('returns plain string unchanged when no field name is provided', () => { + expect( + getHighlightReact('lorem ipsum', undefined, { highlight: { myField: ['lorem ipsum'] } }) + ).toBe('lorem ipsum'); }); - test('returns plain string unchanged when no highlight matches', () => { - check('lorem ipsum', [`${hl('dolor')}`], 'lorem ipsum'); + it('returns plain string unchanged when no hit is provided', () => { + expect(getHighlightReact('lorem ipsum', 'myField', undefined)).toBe('lorem ipsum'); }); - test('highlights a single word at the start', () => { - check('lorem ipsum dolor', [`${hl('lorem')} ipsum dolor`], `${mark('lorem')} ipsum dolor`); + describe('Highlight with DSL substrings', () => { + const check = (value: string, highlights: string[], expected: string) => { + expect( + render(getHighlightReact(value, 'myField', { highlight: { myField: highlights } })) + ).toBe(expected); + }; + + test('returns plain string unchanged when highlights are empty', () => { + check('lorem ipsum', [], 'lorem ipsum'); + }); + + test('returns plain string unchanged when no highlight matches', () => { + check('lorem ipsum', [`${hl('dolor')}`], 'lorem ipsum'); + }); + + test('highlights a single word at the start', () => { + check('lorem ipsum dolor', [`${hl('lorem')} ipsum dolor`], `${mark('lorem')} ipsum dolor`); + }); + + test('highlights a single word in the middle', () => { + check('lorem ipsum dolor', [`lorem ${hl('ipsum')} dolor`], `lorem ${mark('ipsum')} dolor`); + }); + + test('highlights a single word at the end', () => { + check('lorem ipsum dolor', [`lorem ipsum ${hl('dolor')}`], `lorem ipsum ${mark('dolor')}`); + }); + + test('highlights two words within one highlight entry', () => { + check( + 'lorem ipsum dolor sit', + [`lorem ${hl('ipsum')} dolor ${hl('sit')}`], + `lorem ${mark('ipsum')} dolor ${mark('sit')}` + ); + }); + + test('highlights the same word appearing multiple times via multiple highlight entries', () => { + check( + 'lorem ipsum lorem ipsum lorem', + [`${hl('lorem')} ipsum lorem`, `ipsum ${hl('lorem')} ipsum ${hl('lorem')}`], + `${mark('lorem')} ipsum ${mark('lorem')} ipsum ${mark('lorem')}` + ); + }); + + test('highlights words from entries with different context windows', () => { + check( + 'lorem ipsum dolor', + [`${hl('lorem')} ipsum`, `ipsum ${hl('dolor')}`], + `${mark('lorem')} ipsum ${mark('dolor')}` + ); + }); + + test('highlights the entire field value', () => { + check('lorem', [`${hl('lorem')}`], mark('lorem')); + }); + + test('does not highlight partial word matches', () => { + check( + 'loremipsum lorem ipsum', + [`lorem ${hl('ipsum')}`], + `loremipsum lorem ${mark('ipsum')}` + ); + }); + + test('escapes HTML special characters', () => { + check('bold', [`${hl('bold')}`], mark('<b>bold</b>')); + check( + 'lorem ipsum', + [`${hl('ipsum')}`], + `<em>lorem</em> ${mark('ipsum')}` + ); + check('', [], '<script>alert(1)</script>'); + }); + + test('when highlight entries share the same untagged context only the first applies', () => { + check( + 'lorem ipsum dolor sit', + [`${hl('lorem')} ipsum dolor sit`, `lorem ipsum dolor ${hl('sit')}`], + `${mark('lorem')} ipsum dolor sit` + ); + check( + 'elastic search engine', + [`${hl('elastic search')} engine`, `elastic ${hl('search engine')}`], + `${mark('elastic search')} engine` + ); + check('foobar', [`${hl('foo')}bar`, `foo${hl('bar')}`], `${mark('foo')}bar`); + }); }); - test('highlights a single word in the middle', () => { - check('lorem ipsum dolor', [`lorem ${hl('ipsum')} dolor`], `lorem ${mark('ipsum')} dolor`); - }); - - test('highlights a single word at the end', () => { - check('lorem ipsum dolor', [`lorem ipsum ${hl('dolor')}`], `lorem ipsum ${mark('dolor')}`); - }); - - test('highlights two words within one highlight entry', () => { - check( - 'lorem ipsum dolor sit', - [`lorem ${hl('ipsum')} dolor ${hl('sit')}`], - `lorem ${mark('ipsum')} dolor ${mark('sit')}` - ); - }); - - test('highlights the same word appearing multiple times via multiple highlight entries', () => { - check( - 'lorem ipsum lorem ipsum lorem', - [`${hl('lorem')} ipsum lorem`, `ipsum ${hl('lorem')} ipsum ${hl('lorem')}`], - `${mark('lorem')} ipsum ${mark('lorem')} ipsum ${mark('lorem')}` - ); - }); - - test('highlights words from entries with different context windows', () => { - check( - 'lorem ipsum dolor', - [`${hl('lorem')} ipsum`, `ipsum ${hl('dolor')}`], - `${mark('lorem')} ipsum ${mark('dolor')}` - ); - }); - - test('highlights the entire field value', () => { - check('lorem', [`${hl('lorem')}`], mark('lorem')); - }); - - test('does not highlight partial word matches', () => { - check('loremipsum lorem ipsum', [`lorem ${hl('ipsum')}`], `loremipsum lorem ${mark('ipsum')}`); - }); - - test('escapes HTML special characters', () => { - check('bold', [`${hl('bold')}`], mark('<b>bold</b>')); - check( - 'lorem ipsum', - [`${hl('ipsum')}`], - `<em>lorem</em> ${mark('ipsum')}` - ); - check('', [], '<script>alert(1)</script>'); - }); - - test('when highlight entries share the same untagged context only the first applies', () => { - check( - 'lorem ipsum dolor sit', - [`${hl('lorem')} ipsum dolor sit`, `lorem ipsum dolor ${hl('sit')}`], - `${mark('lorem')} ipsum dolor sit` - ); - check( - 'elastic search engine', - [`${hl('elastic search')} engine`, `elastic ${hl('search engine')}`], - `${mark('elastic search')} engine` - ); - check('foobar', [`${hl('foo')}bar`, `foo${hl('bar')}`], `${mark('foo')}bar`); + describe('Highlight with ES|QL inline tags', () => { + const defaultTags = { + preTag: ``, + postTag: '', + }; + const inline = (word: string) => `${defaultTags.preTag}${word}${defaultTags.postTag}`; + + const check = (value: string, tags: FieldFormatHighlightTags, expected: string) => { + expect( + render(getHighlightReact(value, 'myField', { inline_highlights: { myField: tags } })) + ).toBe(expected); + }; + + test('returns plain string unchanged when value has no inline tags', () => { + check('lorem ipsum', defaultTags, 'lorem ipsum'); + }); + + test('returns plain string unchanged when pre or post tag is missing from config', () => { + const hit = { inline_highlights: { myField: { preTag: '', postTag: '' } } }; + expect(getHighlightReact(inline('lorem'), 'myField', hit)).toBe(inline('lorem')); + expect( + getHighlightReact(inline('lorem'), 'myField', { + inline_highlights: { myField: { preTag: '', postTag: '' } }, + }) + ).toBe(inline('lorem')); + }); + + test('highlights a single word at the start', () => { + check(`${inline('lorem')} ipsum dolor`, defaultTags, `${mark('lorem')} ipsum dolor`); + }); + + test('highlights a single word in the middle', () => { + check(`lorem ${inline('ipsum')} dolor`, defaultTags, `lorem ${mark('ipsum')} dolor`); + }); + + test('highlights a single word at the end', () => { + check(`lorem ipsum ${inline('dolor')}`, defaultTags, `lorem ipsum ${mark('dolor')}`); + }); + + test('highlights multiple tagged spans in one value', () => { + check( + `lorem ${inline('ipsum')} dolor ${inline('sit')}`, + defaultTags, + `lorem ${mark('ipsum')} dolor ${mark('sit')}` + ); + }); + + test('highlights the entire field value', () => { + check(inline('lorem'), defaultTags, mark('lorem')); + }); + + test('uses custom pre_tag and post_tag from inline highlight config', () => { + const customTags = { preTag: '', postTag: '' }; + check('foo bar baz', customTags, `foo ${mark('bar')} baz`); + }); + + test('leaves text after an unclosed opening tag unchanged', () => { + check('lorem ipsum', defaultTags, 'lorem <em>ipsum'); + }); }); }); diff --git a/src/platform/plugins/shared/field_formats/common/utils/highlight/highlight_react.tsx b/src/platform/plugins/shared/field_formats/common/utils/highlight/highlight_react.tsx index 8d96c37917341..30a11fe6ae2c1 100644 --- a/src/platform/plugins/shared/field_formats/common/utils/highlight/highlight_react.tsx +++ b/src/platform/plugins/shared/field_formats/common/utils/highlight/highlight_react.tsx @@ -9,10 +9,54 @@ import React from 'react'; import { highlightTags } from './highlight_tags'; +import type { FieldFormatHighlightTags, ReactContextTypeHit } from '../../types'; + +/** + * Resolves the applicable highlight method for a field value. + * + * - DSL: we receive a clean fieldValue and a side list of substrings to be highlighted. + * Example: + * fieldValue = "lorem ipsum dolor" + * fieldName = "myField" + * hit = { highlight: { myField: ["ipsum", "dolor"] } } + * return = "lorem ipsum dolor" + * + * - ES|QL: we receive a fieldValue with inline (or custom) tags. + * Example: + * fieldValue = "lorem ipsum dolor" + * fieldName = "myField" + * hit = { inline_highlights: { myField: { preTag: "", postTag: "" } } } + * return = "lorem ipsum dolor" + */ +export function getHighlightReact( + fieldValue: string, + fieldName: string | undefined, + hit: ReactContextTypeHit | undefined +): React.ReactNode { + if (!fieldName || !hit) { + return fieldValue; + } + + // DSL - we receive a clean fieldValue and a side list of substrings to be highlighted. + const highlightedSubstrings = hit.highlight?.[fieldName]; + if (highlightedSubstrings?.length) { + return highlightWithSubstrings(fieldValue, highlightedSubstrings); + } + + // ES|QL - we receive a fieldValue with inline (or custom) tags. + const inlineHighlightTags = hit.inline_highlights?.[fieldName]; + if (inlineHighlightTags) { + return highlightWithInlineTags(fieldValue, inlineHighlightTags); + } + + return fieldValue; +} /** * Applies search highlighting to a field value, returning React nodes. * + * Receives a field value and a list of substrings that requires highlighting. + * * Step 1: for each highlight, strip its Kibana tags to get the plain substring, * then replace every occurrence of that substring in the working string with * the tagged version. React automatically escapes text node content. @@ -20,7 +64,7 @@ import { highlightTags } from './highlight_tags'; * Step 2: convert the tag-substituted string to React nodes, wrapping each * highlighted span in a element. */ -export function getHighlightReact( +function highlightWithSubstrings( fieldValue: string, highlights: string[] | undefined | null ): React.ReactNode { @@ -76,3 +120,58 @@ export function getHighlightReact( if (nodes.length === 1) return nodes[0]; return <>{nodes}; } + +/** + * Applies highlighting to a field value, returning React nodes. + * + * Receives a field value and the tags used to highlight the value. + * This function replaces them with elements in a safe manner. + */ +function highlightWithInlineTags( + fieldValue: string, + tags: FieldFormatHighlightTags | undefined | null +): React.ReactNode { + if (!tags?.preTag || !tags?.postTag) { + return fieldValue; + } + + const { preTag, postTag } = tags; + if (!fieldValue.includes(preTag)) { + return fieldValue; + } + + const nodes: React.ReactNode[] = []; + let remaining = fieldValue; + let key = 0; + + while (remaining.length > 0) { + const openIndex = remaining.indexOf(preTag); + if (openIndex === -1) { + nodes.push(remaining); + break; + } + + if (openIndex > 0) { + nodes.push(remaining.slice(0, openIndex)); + } + + const contentStart = openIndex + preTag.length; + const closeIndex = remaining.indexOf(postTag, contentStart); + + if (closeIndex === -1) { + nodes.push(remaining.slice(openIndex)); + break; + } + + nodes.push( + + {remaining.slice(contentStart, closeIndex)} + + ); + remaining = remaining.slice(closeIndex + postTag.length); + } + + if (nodes.length === 0) return fieldValue; + if (nodes.length === 1) return nodes[0]; + return <>{nodes}; +}