mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[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:
parent
8775e96e08
commit
54fd40326d
11 changed files with 233 additions and 73 deletions
|
@ -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';
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
|
||||
|
|
103
test/functional/apps/discover/group3/_doc_viewer.ts
Normal file
103
test/functional/apps/discover/group3/_doc_viewer.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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'));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue