diff --git a/src/plugins/discover/public/__mocks__/index_pattern.ts b/src/plugins/discover/public/__mocks__/index_pattern.ts index 8101590f029e10..e74046e9dc1ecc 100644 --- a/src/plugins/discover/public/__mocks__/index_pattern.ts +++ b/src/plugins/discover/public/__mocks__/index_pattern.ts @@ -52,6 +52,13 @@ const fields = [ scripted: true, filterable: false, }, + { + name: 'object.value', + type: 'number', + scripted: false, + filterable: true, + aggregatable: true, + }, ] as IIndexPatternFieldList; fields.getByName = (name: string) => { @@ -64,13 +71,14 @@ const indexPattern = ({ metaFields: ['_index', '_score'], formatField: jest.fn(), flattenHit: undefined, - formatHit: jest.fn((hit) => hit._source), + formatHit: jest.fn((hit) => (hit.fields ? hit.fields : hit._source)), fields, getComputedFields: () => ({ docvalueFields: [], scriptFields: {}, storedFields: ['*'] }), getSourceFiltering: () => ({}), - getFieldByName: () => ({}), + getFieldByName: jest.fn(() => ({})), timeFieldName: '', docvalueFields: [], + getFormatterForField: () => ({ convert: () => 'formatted' }), } as unknown) as IndexPattern; indexPattern.flattenHit = indexPatterns.flattenHitWrapper(indexPattern, indexPattern.metaFields); diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_row.ts b/src/plugins/discover/public/application/angular/doc_table/components/table_row.ts index b527b202ad87d1..12ec9445f4afcd 100644 --- a/src/plugins/discover/public/application/angular/doc_table/components/table_row.ts +++ b/src/plugins/discover/public/application/angular/doc_table/components/table_row.ts @@ -16,7 +16,7 @@ import cellTemplateHtml from '../components/table_row/cell.html'; import truncateByHeightTemplateHtml from '../components/table_row/truncate_by_height.html'; import { getServices } from '../../../../kibana_services'; import { getContextUrl } from '../../../helpers/get_context_url'; -import { formatRow } from '../../helpers'; +import { formatRow, formatTopLevelObject } from '../../helpers'; const TAGS_WITH_WS = />\s+ { + return key.indexOf(`${column}.`) === 0; + }) + ); + newHtmls.push( + cellTemplate({ + timefield: false, + sourcefield: true, + formatted: formatTopLevelObject(row, innerColumns, indexPattern), + filterable: false, + column, + }) + ); + } else { + newHtmls.push( + cellTemplate({ + timefield: false, + sourcefield: column === '_source', + formatted: _displayField(row, column, true), + filterable: isFilterable, + column, + }) + ); + } }); } diff --git a/src/plugins/discover/public/application/angular/helpers/index.ts b/src/plugins/discover/public/application/angular/helpers/index.ts index 3d4893268fdee8..1dd194436cdfb0 100644 --- a/src/plugins/discover/public/application/angular/helpers/index.ts +++ b/src/plugins/discover/public/application/angular/helpers/index.ts @@ -7,5 +7,5 @@ */ export { buildPointSeriesData } from './point_series'; -export { formatRow } from './row_formatter'; +export { formatRow, formatTopLevelObject } from './row_formatter'; export { handleSourceColumnState } from './state_helpers'; diff --git a/src/plugins/discover/public/application/angular/helpers/row_formatter.test.ts b/src/plugins/discover/public/application/angular/helpers/row_formatter.test.ts index 259ad2c2d3d1bd..abbc5294605918 100644 --- a/src/plugins/discover/public/application/angular/helpers/row_formatter.test.ts +++ b/src/plugins/discover/public/application/angular/helpers/row_formatter.test.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { formatRow } from './row_formatter'; +import { formatRow, formatTopLevelObject } from './row_formatter'; import { stubbedSavedObjectIndexPattern } from '../../../__mocks__/stubbed_saved_object_index_pattern'; import { IndexPattern } from '../../../../../data/common/index_patterns/index_patterns'; import { fieldFormatsMock } from '../../../../../data/common/field_formats/mocks'; @@ -43,16 +43,97 @@ describe('Row formatter', () => { foo: 'bar', hello: '<h1>World</h1>', }; - const formatHitMock = jest.fn().mockReturnValueOnce(formatHitReturnValue); + const formatHitMock = jest.fn().mockReturnValue(formatHitReturnValue); beforeEach(() => { - // @ts-ignore + // @ts-expect-error indexPattern.formatHit = formatHitMock; }); it('formats document properly', () => { - expect(formatRow(hit, indexPattern).trim()).toBe( - '
also:
with \\"quotes\\" or 'single qoutes'
number:
42
foo:
bar
hello:
<h1>World</h1>
' + expect(formatRow(hit, indexPattern).trim()).toMatchInlineSnapshot( + `"
also:
with \\\\"quotes\\\\" or 'single qoutes'
number:
42
foo:
bar
hello:
<h1>World</h1>
"` + ); + }); + + it('formats document with highlighted fields first', () => { + expect( + formatRow({ ...hit, highlight: { number: '42' } }, indexPattern).trim() + ).toMatchInlineSnapshot( + `"
number:
42
also:
with \\\\"quotes\\\\" or 'single qoutes'
foo:
bar
hello:
<h1>World</h1>
"` + ); + }); + + it('formats top level objects using formatter', () => { + indexPattern.getFieldByName = jest.fn().mockReturnValue({ + name: 'subfield', + }); + indexPattern.getFormatterForField = jest.fn().mockReturnValue({ + convert: () => 'formatted', + }); + expect( + formatTopLevelObject( + { + fields: { + 'object.value': [5, 10], + }, + }, + { + 'object.value': [5, 10], + }, + indexPattern + ).trim() + ).toMatchInlineSnapshot( + `"
object.value:
formatted, formatted
"` + ); + }); + + it('formats top level objects with subfields and highlights', () => { + indexPattern.getFieldByName = jest.fn().mockReturnValue({ + name: 'subfield', + }); + indexPattern.getFormatterForField = jest.fn().mockReturnValue({ + convert: () => 'formatted', + }); + expect( + formatTopLevelObject( + { + fields: { + 'object.value': [5, 10], + 'object.keys': ['a', 'b'], + }, + highlight: { + 'object.keys': 'a', + }, + }, + { + 'object.value': [5, 10], + 'object.keys': ['a', 'b'], + }, + indexPattern + ).trim() + ).toMatchInlineSnapshot( + `"
object.keys:
formatted, formatted
object.value:
formatted, formatted
"` + ); + }); + + it('formats top level objects, converting unknown fields to string', () => { + indexPattern.getFieldByName = jest.fn(); + indexPattern.getFormatterForField = jest.fn(); + expect( + formatTopLevelObject( + { + fields: { + 'object.value': [5, 10], + }, + }, + { + 'object.value': [5, 10], + }, + indexPattern + ).trim() + ).toMatchInlineSnapshot( + `"
object.value:
5, 10
"` ); }); }); diff --git a/src/plugins/discover/public/application/angular/helpers/row_formatter.ts b/src/plugins/discover/public/application/angular/helpers/row_formatter.ts index 1291bce23c5f30..e17e840e404846 100644 --- a/src/plugins/discover/public/application/angular/helpers/row_formatter.ts +++ b/src/plugins/discover/public/application/angular/helpers/row_formatter.ts @@ -35,3 +35,31 @@ export const formatRow = (hit: Record, indexPattern: IndexPattern) }); return doTemplate({ defPairs: [...highlightPairs, ...sourcePairs] }); }; + +export const formatTopLevelObject = ( + row: Record, + fields: Record, + indexPattern: IndexPattern +) => { + const highlights = row.highlight ?? {}; + const highlightPairs: Array<[string, unknown]> = []; + const sourcePairs: Array<[string, unknown]> = []; + Object.entries(fields).forEach(([key, values]) => { + const field = indexPattern.getFieldByName(key); + const formatter = field + ? indexPattern.getFormatterForField(field) + : { convert: (v: string, ...rest: unknown[]) => String(v) }; + const formatted = values + .map((val: unknown) => + formatter.convert(val, 'html', { + field, + hit: row, + indexPattern, + }) + ) + .join(', '); + const pairs = highlights[key] ? highlightPairs : sourcePairs; + pairs.push([key, formatted]); + }); + return doTemplate({ defPairs: [...highlightPairs, ...sourcePairs] }); +}; diff --git a/src/plugins/discover/public/application/components/discover.tsx b/src/plugins/discover/public/application/components/discover.tsx index e62dccbadcbd08..d0c839aac5a6c6 100644 --- a/src/plugins/discover/public/application/components/discover.tsx +++ b/src/plugins/discover/public/application/components/discover.tsx @@ -410,6 +410,7 @@ export function Discover({ onSetColumns={onSetColumns} onSort={onSort} onResize={onResize} + useNewFieldsApi={useNewFieldsApi} /> )} diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid.tsx index 4f92a49abd2921..a0dcc2c2af4669 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid.tsx @@ -120,6 +120,10 @@ export interface DiscoverGridProps { * Current sort setting */ sort: SortPairArr[]; + /** + * How the data is fetched + */ + useNewFieldsApi: boolean; } export const EuiDataGridMemoized = React.memo((props: EuiDataGridProps) => { @@ -146,6 +150,7 @@ export const DiscoverGrid = ({ settings, showTimeCol, sort, + useNewFieldsApi, }: DiscoverGridProps) => { const displayedColumns = getDisplayedColumns(columns, indexPattern); const defaultColumns = displayedColumns.includes('_source'); @@ -197,9 +202,10 @@ export const DiscoverGrid = ({ getRenderCellValueFn( indexPattern, rows, - rows ? rows.map((hit) => indexPattern.flattenHit(hit)) : [] + rows ? rows.map((hit) => indexPattern.flattenHit(hit)) : [], + useNewFieldsApi ), - [rows, indexPattern] + [rows, indexPattern, useNewFieldsApi] ); /** diff --git a/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.test.tsx b/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.test.tsx index 786d7bc74bf6b9..a1447a9a836727 100644 --- a/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.test.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.test.tsx @@ -10,13 +10,45 @@ import React from 'react'; import { shallow } from 'enzyme'; import { getRenderCellValueFn } from './get_render_cell_value'; import { indexPatternMock } from '../../../__mocks__/index_pattern'; -const rows = [ + +const rowsSource = [ + { + _id: '1', + _index: 'test', + _type: 'test', + _score: 1, + _source: { bytes: 100, extension: '.gz' }, + highlight: { + extension: '@kibana-highlighted-field.gz@/kibana-highlighted-field', + }, + }, +]; + +const rowsFields = [ { _id: '1', _index: 'test', _type: 'test', _score: 1, - _source: { bytes: 100 }, + _source: undefined, + fields: { bytes: [100], extension: ['.gz'] }, + highlight: { + extension: '@kibana-highlighted-field.gz@/kibana-highlighted-field', + }, + }, +]; + +const rowsFieldsWithTopLevelObject = [ + { + _id: '1', + _index: 'test', + _type: 'test', + _score: 1, + _source: undefined, + fields: { 'object.value': [100], extension: ['.gz'] }, + highlight: { + extension: '@kibana-highlighted-field.gz@/kibana-highlighted-field', + }, }, ]; @@ -24,8 +56,9 @@ describe('Discover grid cell rendering', function () { it('renders bytes column correctly', () => { const DiscoverGridCellValue = getRenderCellValueFn( indexPatternMock, - rows, - rows.map((row) => indexPatternMock.flattenHit(row)) + rowsSource, + rowsSource.map((row) => indexPatternMock.flattenHit(row)), + false ); const component = shallow( 100"`); }); + it('renders _source column correctly', () => { const DiscoverGridCellValue = getRenderCellValueFn( indexPatternMock, - rows, - rows.map((row) => indexPatternMock.flattenHit(row)) + rowsSource, + rowsSource.map((row) => indexPatternMock.flattenHit(row)), + false ); const component = shallow( ); - expect(component.html()).toMatchInlineSnapshot( - `"
bytes
100
"` - ); + expect(component).toMatchInlineSnapshot(` + + + extension + + + + bytes + + + + `); }); it('renders _source column correctly when isDetails is set to true', () => { const DiscoverGridCellValue = getRenderCellValueFn( indexPatternMock, - rows, - rows.map((row) => indexPatternMock.flattenHit(row)) + rowsSource, + rowsSource.map((row) => indexPatternMock.flattenHit(row)), + false ); const component = shallow( " + `); + }); + + it('renders fields-based column correctly', () => { + const DiscoverGridCellValue = getRenderCellValueFn( + indexPatternMock, + rowsFields, + rowsFields.map((row) => indexPatternMock.flattenHit(row)), + true + ); + const component = shallow( + + ); + expect(component).toMatchInlineSnapshot(` + + + extension + + + + bytes + + + + `); + }); + + it('renders fields-based column correctly when isDetails is set to true', () => { + const DiscoverGridCellValue = getRenderCellValueFn( + indexPatternMock, + rowsFields, + rowsFields.map((row) => indexPatternMock.flattenHit(row)), + true + ); + const component = shallow( + + ); + expect(component.html()).toMatchInlineSnapshot(` + "{ + "_id": "1", + "_index": "test", + "_type": "test", + "_score": 1, + "fields": { + "bytes": [ + 100 + ], + "extension": [ + ".gz" + ] + }, + "highlight": { + "extension": "@kibana-highlighted-field.gz@/kibana-highlighted-field" } }" `); }); + it('collect object fields and renders them like _source', () => { + const DiscoverGridCellValue = getRenderCellValueFn( + indexPatternMock, + rowsFieldsWithTopLevelObject, + rowsFieldsWithTopLevelObject.map((row) => indexPatternMock.flattenHit(row)), + true + ); + const component = shallow( + + ); + expect(component).toMatchInlineSnapshot(` + + + object.value + + + + `); + }); + + it('collect object fields and renders them like _source with fallback for unmapped', () => { + (indexPatternMock.getFieldByName as jest.Mock).mockReturnValueOnce(undefined); + const DiscoverGridCellValue = getRenderCellValueFn( + indexPatternMock, + rowsFieldsWithTopLevelObject, + rowsFieldsWithTopLevelObject.map((row) => indexPatternMock.flattenHit(row)), + true + ); + const component = shallow( + + ); + expect(component).toMatchInlineSnapshot(` + + + object.value + + + + `); + }); + + it('collect object fields and renders them as json in details', () => { + const DiscoverGridCellValue = getRenderCellValueFn( + indexPatternMock, + rowsFieldsWithTopLevelObject, + rowsFieldsWithTopLevelObject.map((row) => indexPatternMock.flattenHit(row)), + true + ); + const component = shallow( + + ); + expect(component).toMatchInlineSnapshot(` + + { + "object.value": [ + 100 + ] + } + + `); + }); + + it('does not collect subfields when the the column is unmapped but part of fields response', () => { + (indexPatternMock.getFieldByName as jest.Mock).mockReturnValueOnce(undefined); + const DiscoverGridCellValue = getRenderCellValueFn( + indexPatternMock, + rowsFieldsWithTopLevelObject, + rowsFieldsWithTopLevelObject.map((row) => indexPatternMock.flattenHit(row)), + true + ); + const component = shallow( + + ); + expect(component).toMatchInlineSnapshot(` + + `); + }); + it('renders correctly when invalid row is given', () => { const DiscoverGridCellValue = getRenderCellValueFn( indexPatternMock, - rows, - rows.map((row) => indexPatternMock.flattenHit(row)) + rowsSource, + rowsSource.map((row) => indexPatternMock.flattenHit(row)), + false ); const component = shallow( -"`); }); + it('renders correctly when invalid column is given', () => { const DiscoverGridCellValue = getRenderCellValueFn( indexPatternMock, - rows, - rows.map((row) => indexPatternMock.flattenHit(row)) + rowsSource, + rowsSource.map((row) => indexPatternMock.flattenHit(row)), + false ); const component = shallow( > + rowsFlattened: Array>, + useNewFieldsApi: boolean ) => ({ rowIndex, columnId, isDetails, setCellProps }: EuiDataGridCellValueElementProps) => { const row = rows ? (rows[rowIndex] as Record) : undefined; const rowFlattened = rowsFlattened @@ -51,6 +52,60 @@ export const getRenderCellValueFn = ( return -; } + if ( + useNewFieldsApi && + !field && + row && + row.fields && + !(row.fields as Record)[columnId] + ) { + const innerColumns = Object.fromEntries( + Object.entries(row.fields as Record).filter(([key]) => { + return key.indexOf(`${columnId}.`) === 0; + }) + ); + if (isDetails) { + // nicely formatted JSON for the expanded view + return {JSON.stringify(innerColumns, null, 2)}; + } + + // Put the most important fields first + const highlights: Record = (row.highlight as Record) ?? {}; + const highlightPairs: Array<[string, string]> = []; + const sourcePairs: Array<[string, string]> = []; + Object.entries(innerColumns).forEach(([key, values]) => { + const subField = indexPattern.getFieldByName(key); + const formatter = subField + ? indexPattern.getFormatterForField(subField) + : { convert: (v: string, ...rest: unknown[]) => String(v) }; + const formatted = (values as unknown[]) + .map((val: unknown) => + formatter.convert(val, 'html', { + field: subField, + hit: row, + indexPattern, + }) + ) + .join(', '); + const pairs = highlights[key] ? highlightPairs : sourcePairs; + pairs.push([key, formatted]); + }); + + return ( + + {[...highlightPairs, ...sourcePairs].map(([key, value]) => ( + + {key} + + + ))} + + ); + } + if (field && field.type === '_source') { if (isDetails) { // nicely formatted JSON for the expanded view @@ -58,13 +113,23 @@ export const getRenderCellValueFn = ( } const formatted = indexPattern.formatHit(row); + // Put the most important fields first + const highlights: Record = (row.highlight as Record) ?? {}; + const highlightPairs: Array<[string, string]> = []; + const sourcePairs: Array<[string, string]> = []; + + Object.entries(formatted).forEach(([key, val]) => { + const pairs = highlights[key] ? highlightPairs : sourcePairs; + pairs.push([key, val as string]); + }); + return ( - {Object.keys(formatted).map((key) => ( + {[...highlightPairs, ...sourcePairs].map(([key, value]) => ( {key} diff --git a/test/functional/apps/discover/_field_data.ts b/test/functional/apps/discover/_field_data.ts index 3a84158609a18e..3583a8b12c4156 100644 --- a/test/functional/apps/discover/_field_data.ts +++ b/test/functional/apps/discover/_field_data.ts @@ -16,6 +16,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const kibanaServer = getService('kibanaServer'); const toasts = getService('toasts'); const queryBar = getService('queryBar'); + const browser = getService('browser'); const PageObjects = getPageObjects(['common', 'header', 'discover', 'visualize', 'timePicker']); describe('discover tab', function describeIndexTests() { @@ -89,6 +90,27 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(message).to.contain(expectedError); await toasts.dismissToast(); }); + + it('shows top-level object keys', async function () { + await queryBar.setQuery('election'); + await queryBar.submitQuery(); + const currentUrl = await browser.getCurrentUrl(); + const [, hash] = currentUrl.split('#/'); + await PageObjects.common.navigateToUrl( + 'discover', + hash.replace('columns:!(_source)', 'columns:!(relatedContent)'), + { useActualUrl: true } + ); + await retry.try(async function tryingForTime() { + expect(await PageObjects.discover.getDocHeader()).to.be('Time relatedContent'); + }); + + const field = await PageObjects.discover.getDocTableField(1, 1); + expect(field).to.include.string('"og:description":'); + + const marks = await PageObjects.discover.getMarks(); + expect(marks.length).to.be(0); + }); }); }); } diff --git a/test/functional/apps/discover/_field_data_with_fields_api.ts b/test/functional/apps/discover/_field_data_with_fields_api.ts index 73864377476b21..168f718c386021 100644 --- a/test/functional/apps/discover/_field_data_with_fields_api.ts +++ b/test/functional/apps/discover/_field_data_with_fields_api.ts @@ -16,6 +16,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const kibanaServer = getService('kibanaServer'); const toasts = getService('toasts'); const queryBar = getService('queryBar'); + const browser = getService('browser'); const PageObjects = getPageObjects(['common', 'header', 'discover', 'visualize', 'timePicker']); describe('discover tab with new fields API', function describeIndexTests() { @@ -89,6 +90,28 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(message).to.contain(expectedError); await toasts.dismissToast(); }); + + it('shows top-level object keys', async function () { + await queryBar.setQuery('election'); + await queryBar.submitQuery(); + const currentUrl = await browser.getCurrentUrl(); + const [, hash] = currentUrl.split('#/'); + await PageObjects.common.navigateToUrl( + 'discover', + hash.replace('columns:!()', 'columns:!(relatedContent)'), + { useActualUrl: true } + ); + await retry.try(async function tryingForTime() { + expect(await PageObjects.discover.getDocHeader()).to.be('Time relatedContent'); + }); + + const field = await PageObjects.discover.getDocTableField(1, 1); + expect(field).to.include.string('relatedContent.url:'); + + const marks = await PageObjects.discover.getMarks(); + expect(marks.length).to.be(172); + expect(marks.indexOf('election')).to.be(0); + }); }); }); } diff --git a/test/functional/page_objects/discover_page.ts b/test/functional/page_objects/discover_page.ts index 63667afa8289a9..b7b4535641c900 100644 --- a/test/functional/page_objects/discover_page.ts +++ b/test/functional/page_objects/discover_page.ts @@ -194,11 +194,11 @@ export function DiscoverPageProvider({ getService, getPageObjects }: FtrProvider return await row.getVisibleText(); } - public async getDocTableField(index: number) { - const field = await find.byCssSelector( - `tr.kbnDocTable__row:nth-child(${index}) > [data-test-subj='docTableField']` + public async getDocTableField(index: number, cellIndex = 0) { + const fields = await find.allByCssSelector( + `tr.kbnDocTable__row:nth-child(${index}) [data-test-subj='docTableField']` ); - return await field.getVisibleText(); + return await fields[cellIndex].getVisibleText(); } public async skipToEndOfDocTable() {