[UnifiedDocViewer] Field search via wildcard (#168616)

- Closes https://github.com/elastic/kibana/issues/168607

## Summary

This PR allows to search in DocViewer not only for partial matches but
also for wildcard matches.

<img width="400" alt="Screenshot 2023-10-11 at 16 51 36"
src="ec5dc57f-e540-48c6-b43c-d79d64ef1809">
<img width="400" alt="Screenshot 2023-10-11 at 16 51 48"
src="c68a3b70-c195-4da9-bb0c-12ca38db269c">
<img width="400" alt="Screenshot 2023-10-11 at 16 52 24"
src="d2d37976-559d-4cc3-852f-5ad3175808cc">


### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Matthias Wilhelm <matthias.wilhelm@elastic.co>
This commit is contained in:
Julia Rechkunova 2023-10-18 16:52:55 +02:00 committed by GitHub
parent 8775e96e08
commit 54fd40326d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 233 additions and 73 deletions

View file

@ -18,5 +18,9 @@ export { getFieldIconType } from './src/utils/get_field_icon_type';
export { getFieldType } from './src/utils/get_field_type';
export { getFieldTypeDescription } from './src/utils/get_field_type_description';
export { getFieldTypeName, UNKNOWN_FIELD_TYPE_MESSAGE } from './src/utils/get_field_type_name';
export {
fieldNameWildcardMatcher,
getFieldSearchMatchingHighlight,
} from './src/utils/field_name_wildcard_matcher';
export { FieldIcon, type FieldIconProps, getFieldIconProps } from './src/components/field_icon';

View file

@ -0,0 +1,83 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { type DataViewField } from '@kbn/data-views-plugin/common';
import {
fieldNameWildcardMatcher,
getFieldSearchMatchingHighlight,
} from './field_name_wildcard_matcher';
const name = 'test.this_value.maybe';
describe('fieldNameWildcardMatcher', function () {
describe('fieldNameWildcardMatcher()', () => {
it('should work correctly with wildcard', async () => {
expect(fieldNameWildcardMatcher({ displayName: 'test' } as DataViewField, 'no')).toBe(false);
expect(
fieldNameWildcardMatcher({ displayName: 'test', name: 'yes' } as DataViewField, 'yes')
).toBe(true);
const search = 'test*ue';
expect(fieldNameWildcardMatcher({ displayName: 'test' } as DataViewField, search)).toBe(
false
);
expect(fieldNameWildcardMatcher({ displayName: 'test.value' } as DataViewField, search)).toBe(
true
);
expect(fieldNameWildcardMatcher({ name: 'test.this_value' } as DataViewField, search)).toBe(
true
);
expect(fieldNameWildcardMatcher({ name: 'message.test' } as DataViewField, search)).toBe(
false
);
expect(fieldNameWildcardMatcher({ name } as DataViewField, search)).toBe(false);
expect(fieldNameWildcardMatcher({ name } as DataViewField, `${search}*`)).toBe(true);
expect(fieldNameWildcardMatcher({ name } as DataViewField, '*value*')).toBe(true);
});
it('should work correctly with spaces', async () => {
expect(fieldNameWildcardMatcher({ name } as DataViewField, 'test maybe ')).toBe(true);
expect(fieldNameWildcardMatcher({ name } as DataViewField, 'test maybe*')).toBe(true);
expect(fieldNameWildcardMatcher({ name } as DataViewField, 'test. this')).toBe(true);
expect(fieldNameWildcardMatcher({ name } as DataViewField, 'this _value be')).toBe(true);
expect(fieldNameWildcardMatcher({ name } as DataViewField, 'test')).toBe(true);
expect(fieldNameWildcardMatcher({ name } as DataViewField, 'this')).toBe(true);
expect(fieldNameWildcardMatcher({ name } as DataViewField, ' value ')).toBe(true);
expect(fieldNameWildcardMatcher({ name } as DataViewField, 'be')).toBe(true);
expect(fieldNameWildcardMatcher({ name } as DataViewField, 'test this here')).toBe(false);
expect(fieldNameWildcardMatcher({ name } as DataViewField, 'test that')).toBe(false);
expect(fieldNameWildcardMatcher({ name } as DataViewField, ' ')).toBe(false);
expect(fieldNameWildcardMatcher({ name: 'geo.location3' } as DataViewField, '3')).toBe(true);
expect(fieldNameWildcardMatcher({ name: 'geo_location3' } as DataViewField, 'geo 3')).toBe(
true
);
});
it('should be case-insensitive', async () => {
expect(fieldNameWildcardMatcher({ name: 'Test' } as DataViewField, 'test')).toBe(true);
expect(fieldNameWildcardMatcher({ name: 'test' } as DataViewField, 'Test')).toBe(true);
expect(fieldNameWildcardMatcher({ name: 'tesT' } as DataViewField, 'Tes*')).toBe(true);
expect(fieldNameWildcardMatcher({ name: 'tesT' } as DataViewField, 'tes*')).toBe(true);
expect(fieldNameWildcardMatcher({ name: 'tesT' } as DataViewField, 't T')).toBe(true);
expect(fieldNameWildcardMatcher({ name: 'tesT' } as DataViewField, 't t')).toBe(true);
});
});
describe('getFieldSearchMatchingHighlight()', function () {
it('should correctly return only partial match', async () => {
expect(getFieldSearchMatchingHighlight('test this', 'test')).toBe('test');
expect(getFieldSearchMatchingHighlight('test this', 'this')).toBe('this');
expect(getFieldSearchMatchingHighlight('test this')).toBe('');
});
it('should correctly return a full match for a wildcard search', async () => {
expect(getFieldSearchMatchingHighlight('Test this', 'test*')).toBe('Test this');
expect(getFieldSearchMatchingHighlight('test this', '*this')).toBe('test this');
expect(getFieldSearchMatchingHighlight('test this', ' te th')).toBe('test this');
});
});
});

View file

@ -41,3 +41,23 @@ export const fieldNameWildcardMatcher = (
const regExp = makeRegEx(fieldSearchHighlight);
return (!!field.displayName && regExp.test(field.displayName)) || regExp.test(field.name);
};
/**
* Get `highlight` string to be used together with `EuiHighlight`
* @param displayName
* @param fieldSearchHighlight
*/
export function getFieldSearchMatchingHighlight(
displayName: string,
fieldSearchHighlight?: string
): string {
const searchHighlight = (fieldSearchHighlight || '').trim();
if (
(searchHighlight.includes('*') || searchHighlight.includes(' ')) &&
fieldNameWildcardMatcher({ name: displayName }, searchHighlight)
) {
return displayName;
}
return searchHighlight;
}

View file

@ -36,7 +36,7 @@ export function FieldName({
const typeName = getFieldTypeName(fieldType);
const displayName =
fieldMapping && fieldMapping.displayName ? fieldMapping.displayName : fieldName;
const tooltip = displayName !== fieldName ? `${fieldName} (${displayName})` : fieldName;
const tooltip = displayName !== fieldName ? `${displayName} (${fieldName})` : fieldName;
const subTypeMulti = fieldMapping && getDataViewFieldSubtypeMulti(fieldMapping.spec);
const isMultiField = !!subTypeMulti?.multi;

View file

@ -12,9 +12,8 @@ import classnames from 'classnames';
import { FieldButton, type FieldButtonProps } from '@kbn/react-field';
import { EuiButtonIcon, EuiButtonIconProps, EuiHighlight, EuiIcon, EuiToolTip } from '@elastic/eui';
import type { DataViewField } from '@kbn/data-views-plugin/common';
import { FieldIcon, getFieldIconProps } from '@kbn/field-utils';
import { FieldIcon, getFieldIconProps, getFieldSearchMatchingHighlight } from '@kbn/field-utils';
import { type FieldListItem, type GetCustomFieldType } from '../../types';
import { fieldNameWildcardMatcher } from '../../utils/field_name_wildcard_matcher';
import './field_item_button.scss';
/**
@ -197,7 +196,7 @@ export function FieldItemButton<T extends FieldListItem = DataViewField>({
fieldIcon={<FieldIcon {...iconProps} />}
fieldName={
<EuiHighlight
search={getSearchHighlight(displayName, fieldSearchHighlight)}
search={getFieldSearchMatchingHighlight(displayName, fieldSearchHighlight)}
title={title}
data-test-subj={`field-${field.name}`}
>
@ -233,15 +232,3 @@ function FieldConflictInfoIcon() {
</EuiToolTip>
);
}
function getSearchHighlight(displayName: string, fieldSearchHighlight?: string): string {
const searchHighlight = (fieldSearchHighlight || '').trim();
if (
(searchHighlight.includes('*') || searchHighlight.includes(' ')) &&
fieldNameWildcardMatcher({ name: displayName }, searchHighlight)
) {
return displayName;
}
return searchHighlight;
}

View file

@ -10,10 +10,9 @@ import { useMemo, useState } from 'react';
import { htmlIdGenerator } from '@elastic/eui';
import { type DataViewField } from '@kbn/data-views-plugin/common';
import type { CoreStart } from '@kbn/core-lifecycle-browser';
import { type FieldTypeKnown, getFieldIconType } from '@kbn/field-utils';
import { type FieldTypeKnown, getFieldIconType, fieldNameWildcardMatcher } from '@kbn/field-utils';
import { type FieldListFiltersProps } from '../components/field_list_filters';
import { type FieldListItem, GetCustomFieldType } from '../types';
import { fieldNameWildcardMatcher } from '../utils/field_name_wildcard_matcher';
const htmlId = htmlIdGenerator('fieldList');

View file

@ -1,51 +0,0 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { type DataViewField } from '@kbn/data-views-plugin/common';
import { fieldNameWildcardMatcher } from './field_name_wildcard_matcher';
const name = 'test.this_value.maybe';
describe('UnifiedFieldList fieldNameWildcardMatcher()', () => {
it('should work correctly with wildcard', async () => {
expect(fieldNameWildcardMatcher({ displayName: 'test' } as DataViewField, 'no')).toBe(false);
expect(
fieldNameWildcardMatcher({ displayName: 'test', name: 'yes' } as DataViewField, 'yes')
).toBe(true);
const search = 'test*ue';
expect(fieldNameWildcardMatcher({ displayName: 'test' } as DataViewField, search)).toBe(false);
expect(fieldNameWildcardMatcher({ displayName: 'test.value' } as DataViewField, search)).toBe(
true
);
expect(fieldNameWildcardMatcher({ name: 'test.this_value' } as DataViewField, search)).toBe(
true
);
expect(fieldNameWildcardMatcher({ name: 'message.test' } as DataViewField, search)).toBe(false);
expect(fieldNameWildcardMatcher({ name } as DataViewField, search)).toBe(false);
expect(fieldNameWildcardMatcher({ name } as DataViewField, `${search}*`)).toBe(true);
expect(fieldNameWildcardMatcher({ name } as DataViewField, '*value*')).toBe(true);
});
it('should work correctly with spaces', async () => {
expect(fieldNameWildcardMatcher({ name } as DataViewField, 'test maybe ')).toBe(true);
expect(fieldNameWildcardMatcher({ name } as DataViewField, 'test maybe*')).toBe(true);
expect(fieldNameWildcardMatcher({ name } as DataViewField, 'test. this')).toBe(true);
expect(fieldNameWildcardMatcher({ name } as DataViewField, 'this _value be')).toBe(true);
expect(fieldNameWildcardMatcher({ name } as DataViewField, 'test')).toBe(true);
expect(fieldNameWildcardMatcher({ name } as DataViewField, 'this')).toBe(true);
expect(fieldNameWildcardMatcher({ name } as DataViewField, ' value ')).toBe(true);
expect(fieldNameWildcardMatcher({ name } as DataViewField, 'be')).toBe(true);
expect(fieldNameWildcardMatcher({ name } as DataViewField, 'test this here')).toBe(false);
expect(fieldNameWildcardMatcher({ name } as DataViewField, 'test that')).toBe(false);
expect(fieldNameWildcardMatcher({ name } as DataViewField, ' ')).toBe(false);
expect(fieldNameWildcardMatcher({ name: 'geo.location3' } as DataViewField, '3')).toBe(true);
expect(fieldNameWildcardMatcher({ name: 'geo_location3' } as DataViewField, 'geo 3')).toBe(
true
);
});
});

View file

@ -38,6 +38,7 @@ import {
isNestedFieldParent,
usePager,
} from '@kbn/discover-utils';
import { fieldNameWildcardMatcher, getFieldSearchMatchingHighlight } from '@kbn/field-utils';
import type { DocViewRenderProps, FieldRecordLegacy } from '@kbn/unified-doc-viewer/types';
import { FieldName } from '@kbn/unified-doc-viewer';
import { useUnifiedDocViewerServices } from '../../hooks';
@ -246,8 +247,13 @@ export const DocViewerTable = ({
acc.pinnedItems.push(fieldToItem(curFieldName));
} else {
const fieldMapping = mapping(curFieldName);
const displayName = fieldMapping?.displayName ?? curFieldName;
if (displayName.toLowerCase().includes(searchText.toLowerCase())) {
if (
!searchText?.trim() ||
fieldNameWildcardMatcher(
{ name: curFieldName, displayName: fieldMapping?.displayName },
searchText
)
) {
// filter only unpinned fields
acc.restItems.push(fieldToItem(curFieldName));
}
@ -318,7 +324,6 @@ export const DocViewerTable = ({
const renderRows = useCallback(
(items: FieldRecord[]) => {
const highlight = searchText?.toLowerCase();
return items.map(
({
action: { flattenedField, onFilter },
@ -362,7 +367,10 @@ export const DocViewerTable = ({
fieldType={fieldType}
fieldMapping={fieldMapping}
scripted={scripted}
highlight={highlight}
highlight={getFieldSearchMatchingHighlight(
fieldMapping?.displayName ?? field,
searchText
)}
/>
</EuiTableRowCell>
<EuiTableRowCell
@ -405,6 +413,7 @@ export const DocViewerTable = ({
onChange={handleOnChange}
placeholder={searchPlaceholder}
value={searchText}
data-test-subj="unifiedDocViewerFieldsSearchInput"
/>
</EuiFlexItem>

View file

@ -0,0 +1,103 @@
/*
* 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 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 or the Server
* Side Public License, v 1.
*/
import { FtrProviderContext } from '../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
const kibanaServer = getService('kibanaServer');
const PageObjects = getPageObjects(['common', 'discover', 'timePicker', 'header']);
const testSubjects = getService('testSubjects');
const find = getService('find');
const retry = getService('retry');
const dataGrid = getService('dataGrid');
describe('discover doc viewer', function describeIndexTests() {
before(async function () {
await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional');
});
beforeEach(async () => {
await kibanaServer.importExport.load('test/functional/fixtures/kbn_archiver/discover');
await kibanaServer.uiSettings.replace({
defaultIndex: 'logstash-*',
hideAnnouncements: true,
});
await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings();
await PageObjects.common.navigateToApp('discover');
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.discover.waitUntilSearchingHasFinished();
});
afterEach(async () => {
await kibanaServer.importExport.unload('test/functional/fixtures/kbn_archiver/discover');
await kibanaServer.savedObjects.cleanStandardList();
await kibanaServer.uiSettings.replace({});
});
describe('search', function () {
const itemsPerPage = 25;
beforeEach(async () => {
await dataGrid.clickRowToggle();
await PageObjects.discover.isShowingDocViewer();
await retry.waitFor('rendered items', async () => {
return (await find.allByCssSelector('.kbnDocViewer__fieldName')).length === itemsPerPage;
});
});
afterEach(async () => {
const fieldSearch = await testSubjects.find('clearSearchButton');
await fieldSearch.click();
});
it('should be able to search by string', async function () {
await PageObjects.discover.findFieldByNameInDocViewer('geo');
await retry.waitFor('first updates', async () => {
return (await find.allByCssSelector('.kbnDocViewer__fieldName')).length === 4;
});
await PageObjects.discover.findFieldByNameInDocViewer('.s');
await retry.waitFor('second updates', async () => {
return (await find.allByCssSelector('.kbnDocViewer__fieldName')).length === 2;
});
});
it('should be able to search by wildcard', async function () {
await PageObjects.discover.findFieldByNameInDocViewer('relatedContent*image');
await retry.waitFor('updates', async () => {
return (await find.allByCssSelector('.kbnDocViewer__fieldName')).length === 2;
});
});
it('should be able to search with spaces as wildcard', async function () {
await PageObjects.discover.findFieldByNameInDocViewer('relatedContent image');
await retry.waitFor('updates', async () => {
return (await find.allByCssSelector('.kbnDocViewer__fieldName')).length === 4;
});
});
it('should ignore empty search', async function () {
await PageObjects.discover.findFieldByNameInDocViewer(' '); // only spaces
await retry.waitFor('the clear button', async () => {
return await testSubjects.exists('clearSearchButton');
});
// expect no changes in the list
await retry.waitFor('all items', async () => {
return (await find.allByCssSelector('.kbnDocViewer__fieldName')).length === itemsPerPage;
});
});
});
});
}

View file

@ -23,5 +23,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./_drag_drop'));
loadTestFile(require.resolve('./_sidebar'));
loadTestFile(require.resolve('./_request_counts'));
loadTestFile(require.resolve('./_doc_viewer'));
});
}

View file

@ -363,6 +363,11 @@ export class DiscoverPageObject extends FtrService {
return await this.find.byClassName('monaco-editor');
}
public async findFieldByNameInDocViewer(name: string) {
const fieldSearch = await this.testSubjects.find('unifiedDocViewerFieldsSearchInput');
await fieldSearch.type(name);
}
public async getMarks() {
const table = await this.docTable.getTable();
const marks = await table.findAllByTagName('mark');