Skip to content

Commit

Permalink
[Global Search] Add multiword type handling in global search (elastic…
Browse files Browse the repository at this point in the history
…#196087)

## Summary

This PR improves the UX of global search by allowing users to search for
types that consist of multiple words without having to turn them into
phrases (wrapping them in quotes).

For example: 

The following query:
```
hello type:canvas workpad type:enterprise search world tag:new
```
Will get mapped to:
```
hello type:"canvas workpad" type:"enterprise search" world tag:new
```
Which will result in following `Query` object:
```json
{
  "term": "hello world",
  "filters": {
    "tags": ["new"]
    "types": ["canvas workpad", "enterprise search"],
   },
}
```

Fixes: elastic#176877

### 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
  • Loading branch information
kowalczyk-krzysztof authored Oct 16, 2024
1 parent 4ac575b commit 3d28d17
Show file tree
Hide file tree
Showing 3 changed files with 124 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ export const SearchBar: FC<SearchBarProps> = (opts) => {
reportEvent.searchRequest();
}

const rawParams = parseSearchParams(searchValue.toLowerCase());
const rawParams = parseSearchParams(searchValue.toLowerCase(), searchableTypes);
let tagIds: string[] | undefined;
if (taggingApi && rawParams.filters.tags) {
tagIds = rawParams.filters.tags.map(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,33 +9,33 @@ import { parseSearchParams } from './parse_search_params';

describe('parseSearchParams', () => {
it('returns the correct term', () => {
const searchParams = parseSearchParams('tag:(my-tag OR other-tag) hello');
const searchParams = parseSearchParams('tag:(my-tag OR other-tag) hello', []);
expect(searchParams.term).toEqual('hello');
});

it('returns the raw query as `term` in case of parsing error', () => {
const searchParams = parseSearchParams('tag:((()^invalid');
const searchParams = parseSearchParams('tag:((()^invalid', []);
expect(searchParams).toEqual({
term: 'tag:((()^invalid',
filters: {},
});
});

it('returns `undefined` term if query only contains field clauses', () => {
const searchParams = parseSearchParams('tag:(my-tag OR other-tag)');
const searchParams = parseSearchParams('tag:(my-tag OR other-tag)', []);
expect(searchParams.term).toBeUndefined();
});

it('returns correct filters when no field clause is defined', () => {
const searchParams = parseSearchParams('hello');
const searchParams = parseSearchParams('hello', []);
expect(searchParams.filters).toEqual({
tags: undefined,
types: undefined,
});
});

it('returns correct filters when field clauses are present', () => {
const searchParams = parseSearchParams('tag:foo type:bar hello tag:dolly');
const searchParams = parseSearchParams('tag:foo type:bar hello tag:dolly', []);
expect(searchParams).toEqual({
term: 'hello',
filters: {
Expand All @@ -46,7 +46,7 @@ describe('parseSearchParams', () => {
});

it('considers unknown field clauses to be part of the raw search term', () => {
const searchParams = parseSearchParams('tag:foo unknown:bar hello');
const searchParams = parseSearchParams('tag:foo unknown:bar hello', []);
expect(searchParams).toEqual({
term: 'unknown:bar hello',
filters: {
Expand All @@ -56,7 +56,7 @@ describe('parseSearchParams', () => {
});

it('handles aliases field clauses', () => {
const searchParams = parseSearchParams('tag:foo tags:bar type:dash types:board hello');
const searchParams = parseSearchParams('tag:foo tags:bar type:dash types:board hello', []);
expect(searchParams).toEqual({
term: 'hello',
filters: {
Expand All @@ -67,7 +67,7 @@ describe('parseSearchParams', () => {
});

it('converts boolean and number values to string for known filters', () => {
const searchParams = parseSearchParams('tag:42 tags:true type:69 types:false hello');
const searchParams = parseSearchParams('tag:42 tags:true type:69 types:false hello', []);
expect(searchParams).toEqual({
term: 'hello',
filters: {
Expand All @@ -76,4 +76,74 @@ describe('parseSearchParams', () => {
},
});
});

it('converts multiword searchable types to phrases so they get picked up as types', () => {
const mockSearchableMultiwordTypes = ['canvas-workpad', 'enterprise search'];
const searchParams = parseSearchParams(
'type:canvas workpad types:canvas-workpad hello type:enterprise search type:not multiword',
mockSearchableMultiwordTypes
);
expect(searchParams).toEqual({
term: 'hello multiword',
filters: {
types: ['canvas workpad', 'enterprise search', 'not'],
},
});
});

it('parses correctly when multiword types are already quoted', () => {
const mockSearchableMultiwordTypes = ['canvas-workpad'];
const searchParams = parseSearchParams(
`type:"canvas workpad" hello type:"dashboard"`,
mockSearchableMultiwordTypes
);
expect(searchParams).toEqual({
term: 'hello',
filters: {
types: ['canvas workpad', 'dashboard'],
},
});
});

it('parses correctly when there is whitespace between type keyword and value', () => {
const mockSearchableMultiwordTypes = ['canvas-workpad'];
const searchParams = parseSearchParams(
'type: canvas workpad hello type: dashboard',
mockSearchableMultiwordTypes
);
expect(searchParams).toEqual({
term: 'hello',
filters: {
types: ['canvas workpad', 'dashboard'],
},
});
});

it('dedupes duplicate types', () => {
const mockSearchableMultiwordTypes = ['canvas-workpad'];
const searchParams = parseSearchParams(
'type:canvas workpad hello type:dashboard type:canvas-workpad type:canvas workpad type:dashboard',
mockSearchableMultiwordTypes
);
expect(searchParams).toEqual({
term: 'hello',
filters: {
types: ['canvas workpad', 'dashboard'],
},
});
});

it('handles whitespace removal even if there are no multiword types', () => {
const mockSearchableMultiwordTypes: string[] = [];
const searchParams = parseSearchParams(
'hello type: dashboard',
mockSearchableMultiwordTypes
);
expect(searchParams).toEqual({
term: 'hello',
filters: {
types: ['dashboard'],
},
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,54 @@ const aliasMap = {
type: ['types'],
};

export const parseSearchParams = (term: string): ParsedSearchParams => {
// Converts multiword types to phrases by wrapping them in quotes and trimming whitespace after type keyword. Example: type: canvas workpad -> type:"canvas workpad". If the type is already wrapped in quotes or is a single word, it will only trim whitespace after type keyword.
const convertMultiwordTypesToPhrasesAndTrimWhitespace = (
term: string,
multiWordTypes: string[]
): string => {
if (!multiWordTypes.length) {
return term.replace(
/(type:|types:)\s*([^"']*?)\b([^"'\s]+)/gi,
(_, typeKeyword, whitespace, typeValue) => `${typeKeyword}${whitespace.trim()}${typeValue}`
);
}

const typesPattern = multiWordTypes.join('|');
const termReplaceRegex = new RegExp(
`(type:|types:)\\s*([^"']*?)\\b((${typesPattern})\\b|[^\\s"']+)`,
'gi'
);

return term.replace(termReplaceRegex, (_, typeKeyword, whitespace, typeValue) => {
const trimmedTypeKeyword = `${typeKeyword}${whitespace.trim()}`;

// If the type value is already wrapped in quotes, leave it as is
return /['"]/.test(typeValue)
? `${trimmedTypeKeyword}${typeValue}`
: `${trimmedTypeKeyword}"${typeValue}"`;
});
};

const dedupeTypes = (types: FilterValues<string>): FilterValues<string> => [
...new Set(types.map((item) => item.replace(/[-\s]+/g, ' ').trim())),
];

export const parseSearchParams = (term: string, searchableTypes: string[]): ParsedSearchParams => {
const recognizedFields = knownFilters.concat(...Object.values(aliasMap));
let query: Query;

// Finds all multiword types that are separated by whitespace or hyphens
const multiWordSearchableTypesWhitespaceSeperated = searchableTypes
.filter((item) => /[ -]/.test(item))
.map((item) => item.replace(/-/g, ' '));

const modifiedTerm = convertMultiwordTypesToPhrasesAndTrimWhitespace(
term,
multiWordSearchableTypesWhitespaceSeperated
);

try {
query = Query.parse(term, {
query = Query.parse(modifiedTerm, {
schema: { recognizedFields },
});
} catch (e) {
Expand All @@ -42,7 +84,7 @@ export const parseSearchParams = (term: string): ParsedSearchParams => {
term: searchTerm,
filters: {
tags: tags ? valuesToString(tags) : undefined,
types: types ? valuesToString(types) : undefined,
types: types ? dedupeTypes(valuesToString(types)) : undefined,
},
};
};
Expand Down

0 comments on commit 3d28d17

Please sign in to comment.