Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[EuiInMemoryTable] Allow consumers to use non-EQL plain text search with special characters #7175

Merged
merged 9 commits into from
Sep 18, 2023
29 changes: 29 additions & 0 deletions src/components/basic_table/in_memory_table.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1437,4 +1437,33 @@ describe('EuiInMemoryTable', () => {
expect(tableContent.at(2).text()).toBe('baz');
});
});

describe('plain text search', () => {
it('allows searching for any text with special characters in it', () => {
const specialCharacterSearch = '!@#$%^&*(){}+=-_hello:world"`<>?/👋~.,;|';
const items = [
{ title: specialCharacterSearch },
{ title: 'no special characters' },
];
const columns = [{ field: 'title', name: 'Title' }];

const { getByTestSubject, container } = render(
<EuiInMemoryTable
items={items}
searchPlainText
search={{ box: { incremental: true, 'data-test-subj': 'searchbox' } }}
columns={columns}
/>
);
fireEvent.keyUp(getByTestSubject('searchbox'), {
target: { value: specialCharacterSearch },
});

const tableContent = container.querySelectorAll(
'.euiTableRowCell .euiTableCellContent'
);
expect(tableContent).toHaveLength(1); // only 1 match
expect(tableContent[0]).toHaveTextContent(specialCharacterSearch);
});
});
});
63 changes: 53 additions & 10 deletions src/components/basic_table/in_memory_table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,15 @@ import { PropertySort } from '../../services';
import { Pagination as PaginationBarType } from './pagination_bar';
import { isString } from '../../services/predicate';
import { Comparators, Direction } from '../../services/sort';
import { EuiSearchBar, Query } from '../search_bar';
import {
EuiSearchBar,
EuiSearchBarProps,
Query,
SchemaType,
} from '../search_bar/search_bar';
import { EuiSearchBox } from '../search_bar/search_box';
import { EuiSpacer } from '../spacer';
import { CommonProps } from '../common';
import { EuiSearchBarProps } from '../search_bar/search_bar';
import { SchemaType } from '../search_bar/search_box';
import {
EuiTablePaginationProps,
euiTablePaginationDefaults,
Expand Down Expand Up @@ -76,6 +80,15 @@ type InMemoryTableProps<T> = Omit<
* Configures #Search.
*/
search?: Search;
/**
* If passed as true, search ignores all filters and EQL syntax, and anything
* typed into the table search bar is treated as plain text.
*
* This functionality allows users to search for strings with special characters
* such as quotes, parentheses, and colons, which are normally otherwise
* reserved for EQL syntax.
*/
searchPlainText?: boolean;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not 100% convinced this PR is the right way to go, so I'm opening it to get earlier thoughts from the rest of the team.

  1. I don't love this prop name or the "plain text" terminology I'm using - I'm open to other ways to describe "text that does not follow EQL filtering syntax and thus allows users to search for special characters"

  2. I experimented with different ways to pass this flag in the search object, e.g. <EuiInMemoryTable search={{ plainText: true }} />, but it ended up causing a bunch of type headaches down the line so I abandoned it in favor of a top-level prop. Let me know if you have thoughts/preferences on this

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just thinking out loud, what about searchInputFormat: 'eql' | 'text' or just searchFormat?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love it! I'll go with searchFormat!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

searchFormat prop update: feb7a91

Copy link
Member Author

@cee-chen cee-chen Sep 15, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, leaving some extra thoughts just for more context, in case future devs come by this PR and are like "why did they do things this way":

I'm not 100% convinced this PR is the right way to go

When I mentioned this above, I was referring to the fact that this "plain text" search is still essentially using EQL under the hood, primarily to execute over every item passed to the memory table. This potentially isn't as performant as it could be if we were using a more raw EuiFieldSearch input and skipping using EuiSearchBar.Query.parse entirely.

However, in the end, I think this is a simpler approach to go with because while skipping EQL entirely might be more performant, this at least isn't less performant than current behavior, and additionally has much less dev overhead/testing required (we'd have to write a brand new utility to execute/iterate over every single item otherwise).

In the future, something might change to necessitate a bigger break between EQL and non EQL search - but in the interim, I think this is an okay compromise between performance and developer time spent.

/**
* Configures #Pagination
*/
Expand Down Expand Up @@ -521,9 +534,34 @@ export class EuiInMemoryTable<T> extends Component<
}));
};

// Alternative to onQueryChange - allows consumers to specify they want the
// search bar to ignore EQL syntax and only use the searchbar for plain text
onPlainTextSearch = (searchValue: string) => {
const escapedQueryText = searchValue.replaceAll('"', '\\"');
const finalQuery = `"${escapedQueryText}"`;
cee-chen marked this conversation as resolved.
Show resolved Hide resolved
this.setState({
query: EuiSearchBar.Query.parse(finalQuery),
});
};

renderSearchBar() {
const { search } = this.props;
if (search) {
const { search, searchPlainText } = this.props;
if (!search) return;

let searchBar: ReactNode;

if (searchPlainText) {
const _searchBoxProps = (search as EuiSearchBarProps)?.box || {}; // Work around | boolean type
const { schema, ...searchBoxProps } = _searchBoxProps; // Destructure `schema` so it doesn't get rendered to DOM
cee-chen marked this conversation as resolved.
Show resolved Hide resolved

searchBar = (
<EuiSearchBox
query="" // Unused, passed to satisfy Typescript
{...searchBoxProps}
onSearch={this.onPlainTextSearch}
/>
);
} else {
let searchBarProps: Omit<EuiSearchBarProps, 'onChange'> = {};

if (isEuiSearchBarProps(search)) {
Expand All @@ -538,13 +576,17 @@ export class EuiInMemoryTable<T> extends Component<
}
}

return (
<>
<EuiSearchBar onChange={this.onQueryChange} {...searchBarProps} />
<EuiSpacer size="l" />
</>
searchBar = (
<EuiSearchBar onChange={this.onQueryChange} {...searchBarProps} />
);
}

return (
<>
{searchBar}
<EuiSpacer size="l" />
</>
);
}

resolveSearchSchema(): SchemaType {
Expand Down Expand Up @@ -653,6 +695,7 @@ export class EuiInMemoryTable<T> extends Component<
tableLayout,
items: _unuseditems,
search,
searchPlainText,
onTableChange,
executeQueryOptions,
allowNeutralSort,
Expand Down
8 changes: 7 additions & 1 deletion src/components/search_bar/search_bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import React, { Component, ReactElement } from 'react';
import { htmlIdGenerator } from '../../services/accessibility';
import { isString } from '../../services/predicate';
import { EuiFlexGroup, EuiFlexItem } from '../flex';
import { EuiSearchBox, SchemaType } from './search_box';
import { EuiSearchBox } from './search_box';
import { EuiSearchBarFilters, SearchFilterConfig } from './search_filters';
import { Query } from './query';
import { CommonProps } from '../common';
Expand All @@ -36,6 +36,12 @@ interface ArgsWithError {
error: Error;
}

export interface SchemaType {
strict?: boolean;
fields?: any;
flags?: string[];
}

export type EuiSearchBarOnChangeArgs = ArgsWithQuery | ArgsWithError;

type HintPopOverProps = Partial<
Expand Down
138 changes: 70 additions & 68 deletions src/components/search_bar/search_box.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,95 +6,97 @@
* Side Public License, v 1.
*/

import React, { Component } from 'react';
import React, { FunctionComponent, useRef } from 'react';

import { useUpdateEffect } from '../../services';
import { useEuiI18n } from '../i18n';
import { EuiFieldSearch, EuiFieldSearchProps } from '../form';
import { EuiInputPopover } from '../popover';
import { EuiSearchBarProps } from './search_bar';

export interface SchemaType {
strict?: boolean;
fields?: any;
flags?: string[];
}
import { EuiSearchBarProps } from './search_bar';

export interface EuiSearchBoxProps extends EuiFieldSearchProps {
query: string;
// This is optional in EuiFieldSearchProps
onSearch: (queryText: string) => void;
/**
* @default Search...
*/
placeholder?: string;
hint?: {
id: string;
isVisible: boolean;
setIsVisible: (isVisible: boolean) => void;
} & EuiSearchBarProps['hint'];
}

type DefaultProps = Pick<EuiSearchBoxProps, 'placeholder' | 'incremental'>;
export const EuiSearchBox: FunctionComponent<EuiSearchBoxProps> = ({
query,
placeholder,
incremental,
hint,
...rest
}) => {
const inputRef = useRef<HTMLInputElement | null>(null);

export class EuiSearchBox extends Component<EuiSearchBoxProps> {
static defaultProps: DefaultProps = {
placeholder: 'Search...',
incremental: false,
};

private inputElement: HTMLInputElement | null = null;

componentDidUpdate(oldProps: EuiSearchBoxProps) {
if (oldProps.query !== this.props.query && this.inputElement != null) {
this.inputElement.value = this.props.query;
this.inputElement.dispatchEvent(new Event('change'));
useUpdateEffect(() => {
if (inputRef.current) {
inputRef.current.value = query;
inputRef.current.dispatchEvent(new Event('change'));
}
}
}, [query]);

render() {
const { query, incremental, hint, ...rest } = this.props;
const defaultPlaceholder = useEuiI18n(
'euiSearchBox.placeholder',
'Search...'
);
const ariaLabelIncremental = useEuiI18n(
'euiSearchBox.incrementalAriaLabel',
'This is a search bar. As you type, the results lower in the page will automatically filter.'
);
const ariaLabelEnter = useEuiI18n(
'euiSearchBox.ariaLabel',
'This is a search bar. After typing your query, hit enter to filter the results lower in the page.'
);

let ariaLabel;
if (incremental) {
ariaLabel =
'This is a search bar. As you type, the results lower in the page will automatically filter.';
} else {
ariaLabel =
'This is a search bar. After typing your query, hit enter to filter the results lower in the page.';
}
const search = (
<EuiFieldSearch
inputRef={(input) => (inputRef.current = input)}
fullWidth
defaultValue={query}
incremental={incremental}
aria-label={incremental ? ariaLabelIncremental : ariaLabelEnter}
placeholder={placeholder ?? defaultPlaceholder}
onFocus={() => {
hint?.setIsVisible(true);
}}
{...rest}
/>
);

const search = (
<EuiFieldSearch
inputRef={(input) => (this.inputElement = input)}
if (hint) {
return (
<EuiInputPopover
disableFocusTrap
input={search}
isOpen={hint.isVisible}
fullWidth
defaultValue={query}
incremental={incremental}
aria-label={ariaLabel}
onFocus={() => {
hint?.setIsVisible(true);
closePopover={() => {
hint.setIsVisible(false);
}}
panelProps={{
'aria-live': undefined,
'aria-modal': undefined,
role: undefined,
tabIndex: -1,
id: hint.id,
}}
{...rest}
/>
{...hint.popoverProps}
>
{hint.content}
</EuiInputPopover>
);

if (hint) {
return (
<EuiInputPopover
disableFocusTrap
input={search}
isOpen={hint.isVisible}
fullWidth
closePopover={() => {
hint.setIsVisible(false);
}}
panelProps={{
'aria-live': undefined,
'aria-modal': undefined,
role: undefined,
tabIndex: -1,
id: hint.id,
}}
{...hint.popoverProps}
>
{hint.content}
</EuiInputPopover>
);
}

return search;
}
}

return search;
};