From 41dc3216d388edced73aa46724e48692052bd61a Mon Sep 17 00:00:00 2001 From: Jialiang Liang Date: Thu, 12 Feb 2026 13:38:34 -0800 Subject: [PATCH 1/6] [PPL] Search command result field highlighting Signed-off-by: Jialiang Liang --- src/plugins/data/common/data_frames/utils.ts | 3 +++ .../query_enhancements/server/search/ppl_search_strategy.ts | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/src/plugins/data/common/data_frames/utils.ts b/src/plugins/data/common/data_frames/utils.ts index b3b41fded620..942a354e8aff 100644 --- a/src/plugins/data/common/data_frames/utils.ts +++ b/src/plugins/data/common/data_frames/utils.ts @@ -59,6 +59,8 @@ export const convertResult = ({ }, }; + const highlightData = data?.meta?.highlights; + if (data && data.fields && data.fields.length > 0) { for (let index = 0; index < data.size; index++) { const hit: { [key: string]: any } = {}; @@ -122,6 +124,7 @@ export const convertResult = ({ hits.push({ _index: data.name, _source: hit, + ...(highlightData?.[index] && { highlight: highlightData[index] }), }); } } diff --git a/src/plugins/query_enhancements/server/search/ppl_search_strategy.ts b/src/plugins/query_enhancements/server/search/ppl_search_strategy.ts index 89864e9eaa22..719470ffcfd0 100644 --- a/src/plugins/query_enhancements/server/search/ppl_search_strategy.ts +++ b/src/plugins/query_enhancements/server/search/ppl_search_strategy.ts @@ -60,6 +60,10 @@ export const pplSearchStrategyProvider = ( dataFrame.size = rawResponse.data.datarows.length; + if (rawResponse.data.highlights) { + dataFrame.meta = { ...dataFrame.meta, highlights: rawResponse.data.highlights }; + } + if (usage) usage.trackSuccess(rawResponse.took); if (aggConfig) { From 810e7d1f03139f706070db480ca6abc6faa707bf Mon Sep 17 00:00:00 2001 From: Jialiang Liang Date: Thu, 12 Mar 2026 15:12:07 -0700 Subject: [PATCH 2/6] switch the logic to client side and revert two key changes from 9f581bfb49 Signed-off-by: Jialiang Liang --- .../table_cell/source_field_table_cell.tsx | 14 ++++++++++---- src/plugins/query_enhancements/common/utils.ts | 3 +++ .../query_enhancements/server/routes/index.ts | 1 + .../query_enhancements/server/utils/facet.ts | 2 ++ 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/plugins/explore/public/components/data_table/table_cell/source_field_table_cell.tsx b/src/plugins/explore/public/components/data_table/table_cell/source_field_table_cell.tsx index b3988e5cbaae..c782eecbef74 100644 --- a/src/plugins/explore/public/components/data_table/table_cell/source_field_table_cell.tsx +++ b/src/plugins/explore/public/components/data_table/table_cell/source_field_table_cell.tsx @@ -12,6 +12,7 @@ import './source_field_table_cell.scss'; import React, { Fragment } from 'react'; +import dompurify from 'dompurify'; import { IndexPattern, DataView as Dataset } from 'src/plugins/data/public'; import { shortenDottedString } from '../../../helpers/shorten_dotted_string'; import { OpenSearchSearchHit } from '../../../types/doc_views_types'; @@ -31,7 +32,7 @@ const SourceFieldTableCellComponent: React.FC = ({ isShortDots, wrapCellText, }) => { - const formattedRow = dataset.formatHit(row, 'text'); + const formattedRow = dataset.formatHit(row); const metaFields = dataset.metaFields || []; const rawKeys = Object.keys(formattedRow).filter((key) => !metaFields.includes(key)); const keys = isShortDots ? rawKeys.map((k) => shortenDottedString(k)) : rawKeys; @@ -51,9 +52,14 @@ const SourceFieldTableCellComponent: React.FC = ({ {key}: - - {formattedRow[rawKeys[index]]} - + {index !== keys.length - 1 && ' '} ))} diff --git a/src/plugins/query_enhancements/common/utils.ts b/src/plugins/query_enhancements/common/utils.ts index 445f1b1c2844..d940260b2f1b 100644 --- a/src/plugins/query_enhancements/common/utils.ts +++ b/src/plugins/query_enhancements/common/utils.ts @@ -7,6 +7,7 @@ import { Query } from 'src/plugins/data/common'; import { from, timer } from 'rxjs'; import { filter, mergeMap, take, takeWhile } from 'rxjs/operators'; import { stringify } from '@osd/std'; +import { getHighlightRequest } from '../../data/common'; import { EnhancedFetchContext, QueryAggConfig, @@ -70,11 +71,13 @@ export const throwFacetError = (response: any) => { export const fetch = (context: EnhancedFetchContext, query: Query, aggConfig?: QueryAggConfig) => { const { http, path, signal } = context; + const highlight = isPPLSearchQuery(query) ? getHighlightRequest(query.query, true) : undefined; const body = stringify({ query: { ...query, format: 'jdbc' }, aggConfig, pollQueryResultsParams: context.body?.pollQueryResultsParams, timeRange: context.body?.timeRange, + ...(highlight && { highlight }), }); return from( diff --git a/src/plugins/query_enhancements/server/routes/index.ts b/src/plugins/query_enhancements/server/routes/index.ts index 24b02f7117c4..324178da2286 100644 --- a/src/plugins/query_enhancements/server/routes/index.ts +++ b/src/plugins/query_enhancements/server/routes/index.ts @@ -89,6 +89,7 @@ export function defineSearchStrategyRouteProvider(logger: Logger, router: IRoute ), timeRange: schema.maybe(schema.object({}, { unknowns: 'allow' })), options: schema.maybe(schema.object({}, { unknowns: 'allow' })), + highlight: schema.maybe(schema.object({}, { unknowns: 'allow' })), }), }, }, diff --git a/src/plugins/query_enhancements/server/utils/facet.ts b/src/plugins/query_enhancements/server/utils/facet.ts index 1871672f5da6..ce9ffe54bb80 100644 --- a/src/plugins/query_enhancements/server/utils/facet.ts +++ b/src/plugins/query_enhancements/server/utils/facet.ts @@ -56,6 +56,7 @@ export class Facet { const meta = dataSource?.meta; const { format, lang, fetchSize } = request.body; const compressionHeaders = this.getCompressionHeaders(); + const { highlight } = request.body; const params = { body: { query: query.query, @@ -65,6 +66,7 @@ export class Facet { sessionId: meta.sessionId, }), ...(lang && { lang }), + ...(highlight && { highlight }), }, ...(format !== 'jdbc' && { format }), ...(Object.keys(compressionHeaders).length > 0 && { headers: compressionHeaders }), From da4700900d0917039377918394c1dcbb267565bd Mon Sep 17 00:00:00 2001 From: Jialiang Liang Date: Wed, 18 Mar 2026 16:29:03 -0700 Subject: [PATCH 3/6] add tests Signed-off-by: Jialiang Liang --- .../data/common/data_frames/utils.test.ts | 61 +++++++++++++++ .../source_field_table_cell.test.tsx | 11 ++- .../server/search/ppl_search_strategy.test.ts | 75 +++++++++++++++++++ 3 files changed, 146 insertions(+), 1 deletion(-) diff --git a/src/plugins/data/common/data_frames/utils.test.ts b/src/plugins/data/common/data_frames/utils.test.ts index ae99be42f26c..20f7e740416a 100644 --- a/src/plugins/data/common/data_frames/utils.test.ts +++ b/src/plugins/data/common/data_frames/utils.test.ts @@ -433,6 +433,67 @@ describe('convertResult', () => { expect(result.hits.hits[1]._source.foo).toBe(undefined); }); + it('should attach highlight to each hit when body.meta.highlights is present', () => { + const response: IDataFrameResponse = { + took: 100, + timed_out: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: 0, + max_score: 0, + hits: [], + }, + body: { + fields: [ + { name: 'title', type: 'keyword', values: ['OpenSearch', 'Dashboards'] }, + { name: 'message', type: 'keyword', values: ['hello', 'world'] }, + ], + size: 2, + name: 'test-index', + meta: { + highlights: [{ title: ['OpenSearch'] }, { message: ['world'] }], + }, + }, + type: DATA_FRAME_TYPES.DEFAULT, + }; + + const result = convertResult({ response }); + expect(result.hits.hits[0].highlight).toEqual({ title: ['OpenSearch'] }); + expect(result.hits.hits[1].highlight).toEqual({ message: ['world'] }); + }); + + it('should not have highlight on hits when body.meta.highlights is absent', () => { + const response: IDataFrameResponse = { + took: 100, + timed_out: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: 0, + max_score: 0, + hits: [], + }, + body: { + fields: [{ name: 'title', type: 'keyword', values: ['OpenSearch'] }], + size: 1, + name: 'test-index', + }, + type: DATA_FRAME_TYPES.DEFAULT, + }; + + const result = convertResult({ response }); + expect(result.hits.hits[0].highlight).toBeUndefined(); + }); + it('should transform instant data from meta to instantHits format', () => { const instantRows = [ { Time: 1702483200000, cpu: '0', mode: 'idle', Value: 0.95 }, diff --git a/src/plugins/explore/public/components/data_table/table_cell/source_field_table_cell.test.tsx b/src/plugins/explore/public/components/data_table/table_cell/source_field_table_cell.test.tsx index c1ee345b124a..4f3c6d843b2e 100644 --- a/src/plugins/explore/public/components/data_table/table_cell/source_field_table_cell.test.tsx +++ b/src/plugins/explore/public/components/data_table/table_cell/source_field_table_cell.test.tsx @@ -69,7 +69,16 @@ describe('SourceFieldTableCell', () => { renderInTable(defaultProps); - expect(mockDataset.formatHit).toHaveBeenCalledWith(mockRow, 'text'); + expect(mockDataset.formatHit).toHaveBeenCalledWith(mockRow); + }); + + it('renders highlighted HTML content via dangerouslySetInnerHTML', () => { + mockDataset.formatHit.mockReturnValue({ + field1: 'Holmes is here', + }); + renderInTable(defaultProps); + const valueEl = screen.getByTestId('sourceFieldValue'); + expect(valueEl.innerHTML).toContain('Holmes'); }); it('renders field names and values', () => { diff --git a/src/plugins/query_enhancements/server/search/ppl_search_strategy.test.ts b/src/plugins/query_enhancements/server/search/ppl_search_strategy.test.ts index cc9e84d68f0a..fa81e9913bf3 100644 --- a/src/plugins/query_enhancements/server/search/ppl_search_strategy.test.ts +++ b/src/plugins/query_enhancements/server/search/ppl_search_strategy.test.ts @@ -313,6 +313,81 @@ describe('pplSearchStrategyProvider', () => { expect(requestArg.body.fetchSize).toBe(200); }); + it('should attach highlights to dataFrame meta when rawResponse contains highlights', async () => { + const mockHighlights = [{ title: ['OpenSearch'] }, { title: ['Dashboards'] }]; + const mockResponse = { + success: true, + data: { + schema: [ + { name: 'field1', type: 'long' }, + { name: 'field2', type: 'text' }, + ], + datarows: [ + [1, 'value1'], + [2, 'value2'], + ], + highlights: mockHighlights, + }, + took: 100, + }; + const mockFacet = ({ + describeQuery: jest.fn().mockResolvedValue(mockResponse), + } as unknown) as facet.Facet; + jest.spyOn(facet, 'Facet').mockImplementation(() => mockFacet); + (utils.getFields as jest.Mock).mockReturnValue([ + { name: 'field1', type: 'long' }, + { name: 'field2', type: 'text' }, + ]); + + const strategy = pplSearchStrategyProvider(config$, logger, client, usage); + const result = await strategy.search( + mockRequestHandlerContext, + ({ + body: { query: { query: 'source = table', dataset: { id: 'test-dataset' } } }, + } as unknown) as IOpenSearchDashboardsSearchRequest, + {} + ); + + expect(result.body.meta).toBeDefined(); + expect(result.body.meta.highlights).toEqual(mockHighlights); + }); + + it('should not have highlights in meta when rawResponse has no highlights', async () => { + const mockResponse = { + success: true, + data: { + schema: [ + { name: 'field1', type: 'long' }, + { name: 'field2', type: 'text' }, + ], + datarows: [ + [1, 'value1'], + [2, 'value2'], + ], + }, + took: 100, + }; + const mockFacet = ({ + describeQuery: jest.fn().mockResolvedValue(mockResponse), + } as unknown) as facet.Facet; + jest.spyOn(facet, 'Facet').mockImplementation(() => mockFacet); + (utils.getFields as jest.Mock).mockReturnValue([ + { name: 'field1', type: 'long' }, + { name: 'field2', type: 'text' }, + ]); + + const strategy = pplSearchStrategyProvider(config$, logger, client, usage); + const result = await strategy.search( + mockRequestHandlerContext, + ({ + body: { query: { query: 'source = table', dataset: { id: 'test-dataset' } } }, + } as unknown) as IOpenSearchDashboardsSearchRequest, + {} + ); + + expect(result.body.meta?.highlights).toBeUndefined(); + }); + it('should handle empty search response', async () => { const mockResponse = { success: true, From a46fa3b0d21f019cd5dcbe7adc713cf5f6d1280d Mon Sep 17 00:00:00 2001 From: Jialiang Liang Date: Wed, 18 Mar 2026 16:33:01 -0700 Subject: [PATCH 4/6] add changelog Signed-off-by: Jialiang Liang --- changelogs/fragments/11547.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelogs/fragments/11547.yml diff --git a/changelogs/fragments/11547.yml b/changelogs/fragments/11547.yml new file mode 100644 index 000000000000..d34774a64a74 --- /dev/null +++ b/changelogs/fragments/11547.yml @@ -0,0 +1,2 @@ +feat: +- PPL Search Result Highlight Support in Explore ([#11547](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/11547)) \ No newline at end of file From 9490052cf34535c874edf5e7a9bcf4a28ad568cc Mon Sep 17 00:00:00 2001 From: Jialiang Liang Date: Thu, 19 Mar 2026 12:02:32 -0700 Subject: [PATCH 5/6] add change for consume _highlight as a meta field Signed-off-by: Jialiang Liang --- .../server/search/ppl_search_strategy.test.ts | 8 ++++---- .../server/search/ppl_search_strategy.ts | 13 +++++++++++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/plugins/query_enhancements/server/search/ppl_search_strategy.test.ts b/src/plugins/query_enhancements/server/search/ppl_search_strategy.test.ts index fa81e9913bf3..f4b4e4c4f8d3 100644 --- a/src/plugins/query_enhancements/server/search/ppl_search_strategy.test.ts +++ b/src/plugins/query_enhancements/server/search/ppl_search_strategy.test.ts @@ -313,7 +313,7 @@ describe('pplSearchStrategyProvider', () => { expect(requestArg.body.fetchSize).toBe(200); }); - it('should attach highlights to dataFrame meta when rawResponse contains highlights', async () => { + it('should attach highlights to dataFrame meta when rawResponse contains _highlight column', async () => { const mockHighlights = [{ title: ['OpenSearch'] }, { title: ['Dashboards'] }]; const mockResponse = { success: true, @@ -321,12 +321,12 @@ describe('pplSearchStrategyProvider', () => { schema: [ { name: 'field1', type: 'long' }, { name: 'field2', type: 'text' }, + { name: '_highlight', type: 'struct' }, ], datarows: [ - [1, 'value1'], - [2, 'value2'], + [1, 'value1', { title: ['OpenSearch'] }], + [2, 'value2', { title: ['Dashboards'] }], ], - highlights: mockHighlights, }, took: 100, }; diff --git a/src/plugins/query_enhancements/server/search/ppl_search_strategy.ts b/src/plugins/query_enhancements/server/search/ppl_search_strategy.ts index 719470ffcfd0..5ea7db55e3a6 100644 --- a/src/plugins/query_enhancements/server/search/ppl_search_strategy.ts +++ b/src/plugins/query_enhancements/server/search/ppl_search_strategy.ts @@ -51,6 +51,15 @@ export const pplSearchStrategyProvider = ( if (!rawResponse.success) throwFacetError(rawResponse); + // Extract _highlight column from schema/datarows if present + const hlIndex = rawResponse.data.schema?.findIndex((s: any) => s.name === '_highlight'); + let highlights: any[] | undefined; + if (hlIndex !== undefined && hlIndex >= 0) { + highlights = rawResponse.data.datarows?.map((row: any) => row[hlIndex]) ?? []; + rawResponse.data.schema.splice(hlIndex, 1); + rawResponse.data.datarows?.forEach((row: any) => row.splice(hlIndex, 1)); + } + const dataFrame = createDataFrame({ name: query.dataset?.id, schema: rawResponse.data.schema, @@ -60,8 +69,8 @@ export const pplSearchStrategyProvider = ( dataFrame.size = rawResponse.data.datarows.length; - if (rawResponse.data.highlights) { - dataFrame.meta = { ...dataFrame.meta, highlights: rawResponse.data.highlights }; + if (highlights) { + dataFrame.meta = { ...dataFrame.meta, highlights }; } if (usage) usage.trackSuccess(rawResponse.took); From 09d30a6fb1ab4e5885283bae4aaf53ded7b87c76 Mon Sep 17 00:00:00 2001 From: Jialiang Liang Date: Thu, 19 Mar 2026 16:26:02 -0700 Subject: [PATCH 6/6] Eliminate dompurify Signed-off-by: Jialiang Liang --- .../source_field_table_cell.test.tsx | 26 +++++-- .../table_cell/source_field_table_cell.tsx | 15 ++-- .../data/common/field_formats/index.ts | 7 +- .../highlight/highlight_display.test.tsx | 74 +++++++++++++++++++ .../utils/highlight/highlight_display.tsx | 53 +++++++++++++ .../field_formats/utils/highlight/index.ts | 2 + .../data/common/field_formats/utils/index.ts | 8 +- .../source_field_table_cell.test.tsx | 37 +++++++--- .../table_cell/source_field_table_cell.tsx | 15 ++-- 9 files changed, 200 insertions(+), 37 deletions(-) create mode 100644 src/plugins/data/common/field_formats/utils/highlight/highlight_display.test.tsx create mode 100644 src/plugins/data/common/field_formats/utils/highlight/highlight_display.tsx diff --git a/src/plugins/agent_traces/public/components/data_table/table_cell/source_field_table_cell.test.tsx b/src/plugins/agent_traces/public/components/data_table/table_cell/source_field_table_cell.test.tsx index 1fcdc663defc..502da15de366 100644 --- a/src/plugins/agent_traces/public/components/data_table/table_cell/source_field_table_cell.test.tsx +++ b/src/plugins/agent_traces/public/components/data_table/table_cell/source_field_table_cell.test.tsx @@ -16,9 +16,8 @@ jest.mock('../../../helpers/shorten_dotted_string', () => ({ shortenDottedString: jest.fn((str) => `short_${str}`), })); -jest.mock('dompurify', () => ({ - sanitize: jest.fn((str) => str), -})); +const PRE = '@opensearch-dashboards-highlighted-field@'; +const POST = '@/opensearch-dashboards-highlighted-field@'; describe('SourceFieldTableCell', () => { const mockDataset = { @@ -64,12 +63,29 @@ describe('SourceFieldTableCell', () => { expect(cell).toHaveClass('agentTracesDocTableCell__source'); }); - it('calls formatHit with the correct row', () => { + it('calls formatHit with the correct row and text type', () => { mockDataset.formatHit.mockReturnValue({ field1: 'value1' }); renderInTable(defaultProps); - expect(mockDataset.formatHit).toHaveBeenCalledWith(mockRow); + expect(mockDataset.formatHit).toHaveBeenCalledWith(mockRow, 'text'); + }); + + it('renders highlighted content from row.highlight as mark elements', () => { + mockDataset.formatHit.mockReturnValue({ firstname: 'Holmes' }); + const rowWithHighlight = { + ...mockRow, + highlight: { + firstname: [`${PRE}Holmes${POST}`], + }, + }; + + renderInTable({ ...defaultProps, row: rowWithHighlight }); + + const valueEl = screen.getByTestId('sourceFieldValue'); + const mark = valueEl.querySelector('mark'); + expect(mark).toBeInTheDocument(); + expect(mark!.textContent).toBe('Holmes'); }); it('renders field names and values', () => { diff --git a/src/plugins/agent_traces/public/components/data_table/table_cell/source_field_table_cell.tsx b/src/plugins/agent_traces/public/components/data_table/table_cell/source_field_table_cell.tsx index 9bc510a060d3..45ca7e1ed7ab 100644 --- a/src/plugins/agent_traces/public/components/data_table/table_cell/source_field_table_cell.tsx +++ b/src/plugins/agent_traces/public/components/data_table/table_cell/source_field_table_cell.tsx @@ -12,8 +12,8 @@ import './source_field_table_cell.scss'; import React, { Fragment } from 'react'; -import dompurify from 'dompurify'; import { IndexPattern, DataView as Dataset } from 'src/plugins/data/public'; +import { getDisplayValue } from '../../../../../data/common'; import { shortenDottedString } from '../../../helpers/shorten_dotted_string'; import { OpenSearchSearchHit } from '../../../types/doc_views_types'; @@ -32,7 +32,7 @@ export const SourceFieldTableCell: React.FC = ({ isShortDots, wrapCellText, }) => { - const formattedRow = dataset.formatHit(row); + const formattedRow = dataset.formatHit(row, 'text'); const metaFields = dataset.metaFields || []; const rawKeys = Object.keys(formattedRow).filter((key) => !metaFields.includes(key)); const keys = isShortDots ? rawKeys.map((k) => shortenDottedString(k)) : rawKeys; @@ -52,14 +52,9 @@ export const SourceFieldTableCell: React.FC = ({ {key}: - + + {getDisplayValue(rawKeys[index], formattedRow[rawKeys[index]], row.highlight)} + {index !== keys.length - 1 && ' '} ))} diff --git a/src/plugins/data/common/field_formats/index.ts b/src/plugins/data/common/field_formats/index.ts index 099f01eb3296..aba4faa1ec8f 100644 --- a/src/plugins/data/common/field_formats/index.ts +++ b/src/plugins/data/common/field_formats/index.ts @@ -50,7 +50,12 @@ export { TruncateFormat, } from './converters'; -export { getHighlightRequest } from './utils'; +export { + getHighlightRequest, + highlightTags, + parseHighlightedValue, + getDisplayValue, +} from './utils'; export { DEFAULT_CONVERTER_COLOR } from './constants/color_default'; export { FIELD_FORMAT_IDS } from './types'; diff --git a/src/plugins/data/common/field_formats/utils/highlight/highlight_display.test.tsx b/src/plugins/data/common/field_formats/utils/highlight/highlight_display.test.tsx new file mode 100644 index 000000000000..acb6329e49b2 --- /dev/null +++ b/src/plugins/data/common/field_formats/utils/highlight/highlight_display.test.tsx @@ -0,0 +1,74 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { parseHighlightedValue, getDisplayValue } from './highlight_display'; + +const PRE = '@opensearch-dashboards-highlighted-field@'; +const POST = '@/opensearch-dashboards-highlighted-field@'; + +describe('parseHighlightedValue', () => { + it('returns plain string when no highlight tags present', () => { + expect(parseHighlightedValue('plain text')).toBe('plain text'); + }); + + it('returns non-string values as-is', () => { + expect(parseHighlightedValue(123 as any)).toBe(123); + }); + + it('parses single highlight tag into mark element', () => { + const result = parseHighlightedValue(`${PRE}Holmes${POST}`); + const { container } = render({result}); + const mark = container.querySelector('mark'); + expect(mark).toBeTruthy(); + expect(mark!.textContent).toBe('Holmes'); + }); + + it('parses multiple highlight tags', () => { + const result = parseHighlightedValue(`${PRE}Holmes${POST} and ${PRE}Bond${POST}`); + const { container } = render({result}); + const marks = container.querySelectorAll('mark'); + expect(marks.length).toBe(2); + expect(marks[0].textContent).toBe('Holmes'); + expect(marks[1].textContent).toBe('Bond'); + }); + + it('preserves text around highlight tags', () => { + const result = parseHighlightedValue(`before ${PRE}match${POST} after`); + const { container } = render({result}); + expect(container.textContent).toBe('before match after'); + expect(container.querySelector('mark')!.textContent).toBe('match'); + }); +}); + +describe('getDisplayValue', () => { + it('returns formatted value when no highlight exists', () => { + expect(getDisplayValue('field1', 'plain value')).toBe('plain value'); + }); + + it('returns formatted value when highlight object is undefined', () => { + expect(getDisplayValue('field1', 'plain value', undefined)).toBe('plain value'); + }); + + it('returns formatted value when field has no highlight entry', () => { + expect(getDisplayValue('field1', 'plain value', { other: ['x'] })).toBe('plain value'); + }); + + it('returns highlighted React nodes when field has highlight fragments', () => { + const highlight = { firstname: [`${PRE}Holmes${POST}`] }; + const result = getDisplayValue('firstname', 'Holmes', highlight); + const { container } = render({result}); + expect(container.querySelector('mark')!.textContent).toBe('Holmes'); + }); + + it('joins multiple highlight fragments', () => { + const highlight = { firstname: [`${PRE}Holmes${POST}`, `${PRE}Bond${POST}`] }; + const result = getDisplayValue('firstname', 'Holmes Bond', highlight); + const { container } = render({result}); + const marks = container.querySelectorAll('mark'); + expect(marks.length).toBe(2); + }); +}); diff --git a/src/plugins/data/common/field_formats/utils/highlight/highlight_display.tsx b/src/plugins/data/common/field_formats/utils/highlight/highlight_display.tsx new file mode 100644 index 000000000000..72f84903cae9 --- /dev/null +++ b/src/plugins/data/common/field_formats/utils/highlight/highlight_display.tsx @@ -0,0 +1,53 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { highlightTags } from './highlight_tags'; + +const highlightRegex = new RegExp(`${highlightTags.pre}(.*?)${highlightTags.post}`, 'g'); + +/** + * Parse a string containing highlight custom tags into React nodes with elements. + * Returns the original string if no highlight tags are found. + */ +export const parseHighlightedValue = (value: string): React.ReactNode => { + if (typeof value !== 'string') return value; + + const parts: React.ReactNode[] = []; + let lastIndex = 0; + let match; + + highlightRegex.lastIndex = 0; + while ((match = highlightRegex.exec(value)) !== null) { + if (match.index > lastIndex) { + parts.push(value.substring(lastIndex, match.index)); + } + parts.push({match[1]}); + lastIndex = highlightRegex.lastIndex; + } + + if (parts.length === 0) return value; + + if (lastIndex < value.length) { + parts.push(value.substring(lastIndex)); + } + + return parts; +}; + +/** + * Get the display value for a field, using highlight fragments if available, + * otherwise falling back to the formatted text value. + */ +export const getDisplayValue = ( + fieldName: string, + formattedValue: string, + highlight?: Record +): React.ReactNode => { + if (highlight && highlight[fieldName] && highlight[fieldName].length > 0) { + return parseHighlightedValue(highlight[fieldName].join(' ')); + } + return formattedValue; +}; diff --git a/src/plugins/data/common/field_formats/utils/highlight/index.ts b/src/plugins/data/common/field_formats/utils/highlight/index.ts index e61148c620be..a9d0c0e054bf 100644 --- a/src/plugins/data/common/field_formats/utils/highlight/index.ts +++ b/src/plugins/data/common/field_formats/utils/highlight/index.ts @@ -30,3 +30,5 @@ export { getHighlightHtml } from './highlight_html'; export { getHighlightRequest } from './highlight_request'; +export { highlightTags } from './highlight_tags'; +export { parseHighlightedValue, getDisplayValue } from './highlight_display'; diff --git a/src/plugins/data/common/field_formats/utils/index.ts b/src/plugins/data/common/field_formats/utils/index.ts index a13a97e9ea19..69f49bdc9e10 100644 --- a/src/plugins/data/common/field_formats/utils/index.ts +++ b/src/plugins/data/common/field_formats/utils/index.ts @@ -32,6 +32,12 @@ import { SerializedFieldFormat } from '../../../../expressions/common/types'; import { IFieldFormat } from '../index'; export { asPrettyString } from './as_pretty_string'; -export { getHighlightHtml, getHighlightRequest } from './highlight'; +export { + getHighlightHtml, + getHighlightRequest, + highlightTags, + parseHighlightedValue, + getDisplayValue, +} from './highlight'; export type FormatFactory = (mapping?: SerializedFieldFormat) => IFieldFormat; diff --git a/src/plugins/explore/public/components/data_table/table_cell/source_field_table_cell.test.tsx b/src/plugins/explore/public/components/data_table/table_cell/source_field_table_cell.test.tsx index 4f3c6d843b2e..cfb5c0eb00d1 100644 --- a/src/plugins/explore/public/components/data_table/table_cell/source_field_table_cell.test.tsx +++ b/src/plugins/explore/public/components/data_table/table_cell/source_field_table_cell.test.tsx @@ -16,9 +16,8 @@ jest.mock('../../../helpers/shorten_dotted_string', () => ({ shortenDottedString: jest.fn((str) => `short_${str}`), })); -jest.mock('dompurify', () => ({ - sanitize: jest.fn((str) => str), -})); +const PRE = '@opensearch-dashboards-highlighted-field@'; +const POST = '@/opensearch-dashboards-highlighted-field@'; describe('SourceFieldTableCell', () => { const mockDataset = { @@ -64,21 +63,39 @@ describe('SourceFieldTableCell', () => { expect(cell).toHaveClass('exploreDocTableCell__source'); }); - it('calls formatHit with the correct row', () => { + it('calls formatHit with the correct row and text type', () => { mockDataset.formatHit.mockReturnValue({ field1: 'value1' }); renderInTable(defaultProps); - expect(mockDataset.formatHit).toHaveBeenCalledWith(mockRow); + expect(mockDataset.formatHit).toHaveBeenCalledWith(mockRow, 'text'); }); - it('renders highlighted HTML content via dangerouslySetInnerHTML', () => { - mockDataset.formatHit.mockReturnValue({ - field1: 'Holmes is here', - }); + it('renders highlighted content from row.highlight as mark elements', () => { + mockDataset.formatHit.mockReturnValue({ firstname: 'Holmes' }); + const rowWithHighlight = { + ...mockRow, + highlight: { + firstname: [`${PRE}Holmes${POST}`], + }, + }; + + renderInTable({ ...defaultProps, row: rowWithHighlight }); + + const valueEl = screen.getByTestId('sourceFieldValue'); + const mark = valueEl.querySelector('mark'); + expect(mark).toBeInTheDocument(); + expect(mark!.textContent).toBe('Holmes'); + }); + + it('renders plain text when no highlight exists for field', () => { + mockDataset.formatHit.mockReturnValue({ firstname: 'Holmes' }); + renderInTable(defaultProps); + const valueEl = screen.getByTestId('sourceFieldValue'); - expect(valueEl.innerHTML).toContain('Holmes'); + expect(valueEl.textContent).toBe('Holmes'); + expect(valueEl.querySelector('mark')).toBeNull(); }); it('renders field names and values', () => { diff --git a/src/plugins/explore/public/components/data_table/table_cell/source_field_table_cell.tsx b/src/plugins/explore/public/components/data_table/table_cell/source_field_table_cell.tsx index c782eecbef74..2ec2e1fe4901 100644 --- a/src/plugins/explore/public/components/data_table/table_cell/source_field_table_cell.tsx +++ b/src/plugins/explore/public/components/data_table/table_cell/source_field_table_cell.tsx @@ -12,8 +12,8 @@ import './source_field_table_cell.scss'; import React, { Fragment } from 'react'; -import dompurify from 'dompurify'; import { IndexPattern, DataView as Dataset } from 'src/plugins/data/public'; +import { getDisplayValue } from '../../../../../data/common'; import { shortenDottedString } from '../../../helpers/shorten_dotted_string'; import { OpenSearchSearchHit } from '../../../types/doc_views_types'; @@ -32,7 +32,7 @@ const SourceFieldTableCellComponent: React.FC = ({ isShortDots, wrapCellText, }) => { - const formattedRow = dataset.formatHit(row); + const formattedRow = dataset.formatHit(row, 'text'); const metaFields = dataset.metaFields || []; const rawKeys = Object.keys(formattedRow).filter((key) => !metaFields.includes(key)); const keys = isShortDots ? rawKeys.map((k) => shortenDottedString(k)) : rawKeys; @@ -52,14 +52,9 @@ const SourceFieldTableCellComponent: React.FC = ({ {key}: - + + {getDisplayValue(rawKeys[index], formattedRow[rawKeys[index]], row.highlight)} + {index !== keys.length - 1 && ' '} ))}