Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelogs/fragments/11547.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
feat:
- PPL Search Result Highlight Support in Explore ([#11547](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/11547))
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -32,7 +32,7 @@ export const SourceFieldTableCell: React.FC<SourceFieldTableCellProps> = ({
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;
Expand All @@ -52,14 +52,9 @@ export const SourceFieldTableCell: React.FC<SourceFieldTableCellProps> = ({
<span className="source__key" data-test-subj="sourceFieldKey">
{key}:
</span>
<span
className="source__value"
data-test-subj="sourceFieldValue"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: dompurify.sanitize(formattedRow[rawKeys[index]]),
}}
/>
<span className="source__value" data-test-subj="sourceFieldValue">
{getDisplayValue(rawKeys[index], formattedRow[rawKeys[index]], row.highlight)}
</span>
{index !== keys.length - 1 && ' '}
</Fragment>
))}
Expand Down
61 changes: 61 additions & 0 deletions src/plugins/data/common/data_frames/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: ['<em>OpenSearch</em>'] }, { message: ['<em>world</em>'] }],
},
},
type: DATA_FRAME_TYPES.DEFAULT,
};

const result = convertResult({ response });
expect(result.hits.hits[0].highlight).toEqual({ title: ['<em>OpenSearch</em>'] });
expect(result.hits.hits[1].highlight).toEqual({ message: ['<em>world</em>'] });
});

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 },
Expand Down
3 changes: 3 additions & 0 deletions src/plugins/data/common/data_frames/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } = {};
Expand Down Expand Up @@ -122,6 +124,7 @@ export const convertResult = ({
hits.push({
_index: data.name,
_source: hit,
...(highlightData?.[index] && { highlight: highlightData[index] }),
});
}
}
Expand Down
7 changes: 6 additions & 1 deletion src/plugins/data/common/field_formats/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<span>{result}</span>);
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(<span>{result}</span>);
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(<span>{result}</span>);
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(<span>{result}</span>);
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(<span>{result}</span>);
const marks = container.querySelectorAll('mark');
expect(marks.length).toBe(2);
});
});
Original file line number Diff line number Diff line change
@@ -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 <mark> 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(<mark key={match.index}>{match[1]}</mark>);
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<string, string[]>
): React.ReactNode => {
if (highlight && highlight[fieldName] && highlight[fieldName].length > 0) {
return parseHighlightedValue(highlight[fieldName].join(' '));
}
return formattedValue;
};
Original file line number Diff line number Diff line change
Expand Up @@ -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';
8 changes: 7 additions & 1 deletion src/plugins/data/common/field_formats/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -64,14 +63,41 @@ 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, '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 plain text when no highlight exists for field', () => {
mockDataset.formatHit.mockReturnValue({ firstname: 'Holmes' });

renderInTable(defaultProps);

const valueEl = screen.getByTestId('sourceFieldValue');
expect(valueEl.textContent).toBe('Holmes');
expect(valueEl.querySelector('mark')).toBeNull();
});

it('renders field names and values', () => {
mockDataset.formatHit.mockReturnValue({
field1: 'value1',
Expand Down
Loading
Loading