diff --git a/packages/eui/changelogs/upcoming/9142.md b/packages/eui/changelogs/upcoming/9142.md new file mode 100644 index 00000000000..b1df289a90d --- /dev/null +++ b/packages/eui/changelogs/upcoming/9142.md @@ -0,0 +1,3 @@ +**Bug fixes** + +- Fixed `EuiInMemoryTable` support for controlled search for plain text (when `searchFormat="text"`) by properly handling `search.query` and `search.defaulQuery` diff --git a/packages/eui/src/components/basic_table/in_memory_table.test.tsx b/packages/eui/src/components/basic_table/in_memory_table.test.tsx index ecd940ba313..817ceee1527 100644 --- a/packages/eui/src/components/basic_table/in_memory_table.test.tsx +++ b/packages/eui/src/components/basic_table/in_memory_table.test.tsx @@ -1533,7 +1533,7 @@ describe('EuiInMemoryTable', () => { ); expect(mockOnChange).toHaveBeenCalledTimes(1); expect(mockOnChange).toHaveBeenCalledWith({ - query: Query.parse(`"${TEXT}"`), + query: null, queryText: TEXT, error: null, }); @@ -1569,5 +1569,108 @@ describe('EuiInMemoryTable', () => { ); expect(tableContent).toHaveLength(2); // 2 matches for "ba" }); + + it('supports controlled operation, query is reflected in the search box', () => { + const items = [{ title: 'foo' }, { title: 'bar' }, { title: 'baz' }]; + const columns = [{ field: 'title', name: 'Title' }]; + const CONTROLLED_QUERY = 'controlled search'; + + const { getByTestSubject, rerender } = render( + + ); + + const searchbox = getByTestSubject('searchbox') as HTMLInputElement; + expect(searchbox.value).toBe(CONTROLLED_QUERY); + + // Update the controlled query + const UPDATED_QUERY = 'updated search'; + rerender( + + ); + + expect(searchbox.value).toBe(UPDATED_QUERY); + }); + + it('renders defaultQuery in the search box on initial render', () => { + const items = [{ title: 'foo' }, { title: 'bar' }, { title: 'baz' }]; + const columns = [{ field: 'title', name: 'Title' }]; + const DEFAULT_QUERY = 'default search text'; + + const { getByTestSubject } = render( + + ); + + const searchbox = getByTestSubject('searchbox') as HTMLInputElement; + + expect(searchbox.value).toBe(DEFAULT_QUERY); + + fireEvent.keyUp(searchbox, { + target: { value: 'something else' }, + }); + + expect(searchbox.value).toBe('something else'); + }); + + it('ignores Query objects in the search box', () => { + const items = [{ title: 'foo' }, { title: 'bar' }, { title: 'baz' }]; + const columns = [{ field: 'title', name: 'Title' }]; + const query = Query.parse('ba'); + + const { getByTestSubject, rerender } = render( + + ); + + const searchbox = getByTestSubject('searchbox') as HTMLInputElement; + + // not `[object Object]` + expect(searchbox.value).toBe(''); + + rerender( + + ); + + // not `[object Object]` + expect(searchbox.value).toBe(''); + }); }); }); diff --git a/packages/eui/src/components/basic_table/in_memory_table.tsx b/packages/eui/src/components/basic_table/in_memory_table.tsx index 03d636e1110..5ac3b93fd9d 100644 --- a/packages/eui/src/components/basic_table/in_memory_table.tsx +++ b/packages/eui/src/components/basic_table/in_memory_table.tsx @@ -85,7 +85,11 @@ type InMemoryTableProps = Omit< */ noItemsMessage?: ReactNode; /** - * Configures {@link Search}. + * Configures the search bar. Can be `true` for defaults, + * or an {@link EuiSearchBarProps} object. + * + * When `searchFormat="text"`, `query` and `defaultQuery` must be strings + * ({@link Query} objects are ignored). */ search?: Search; /** @@ -93,6 +97,7 @@ type InMemoryTableProps = Omit< * * However, certain special characters (such as quotes, parentheses, and colons) * are reserved for EQL syntax and will error if used. + * * If your table does not require filter search and instead requires searching for certain * symbols, use a plain `text` search format instead (note that filters will be ignored * in this format). @@ -159,7 +164,7 @@ interface State { search?: Search; }; search?: Search; - query: Query | null; + query: Query | string | null; pageIndex: number; pageSize?: number; pageSizeOptions?: number[]; @@ -169,23 +174,34 @@ interface State { showPerPageOptions: boolean | undefined; } +/** + * Extracts and formats a query from search props based on the search format + * @param search - The search configuration + * @param defaultQuery - Whether to use the defaultQuery property as fallback + * @param searchFormat - The search format: 'eql' for parsed queries, 'text' for plain text + * @returns Formatted query string or Query object + */ const getQueryFromSearch = ( search: Search | undefined, - defaultQuery: boolean -) => { - let query: Query | string; + defaultQuery: boolean, + searchFormat: InMemoryTableProps<{}>['searchFormat'] +): Query | string => { if (!search) { - query = ''; - } else { - query = - (defaultQuery - ? (search as EuiSearchBarProps).defaultQuery || - (search as EuiSearchBarProps).query || - '' - : (search as EuiSearchBarProps).query) || ''; + return searchFormat === 'text' ? '""' : ''; + } + + const searchProps = search as EuiSearchBarProps; + const queryString = defaultQuery + ? searchProps.defaultQuery ?? searchProps.query ?? '' + : searchProps.query ?? ''; + + if (searchFormat === 'text') { + return `"${queryString}"`; } - return isString(query) ? EuiSearchBar.Query.parse(query) : query; + return isString(queryString) + ? EuiSearchBar.Query.parse(queryString) + : queryString; }; const getInitialPagination = ( @@ -398,7 +414,11 @@ export class EuiInMemoryTable extends Component< ...updatedPrevState.prevProps, search: nextProps.search, }, - query: getQueryFromSearch(nextProps.search, false), + query: getQueryFromSearch( + nextProps.search, + false, + nextProps.searchFormat ?? 'eql' + ), }; } if (updatedPrevState !== prevState) { @@ -423,7 +443,7 @@ export class EuiInMemoryTable extends Component< search, }, search: search, - query: getQueryFromSearch(search, true), + query: getQueryFromSearch(search, true, props.searchFormat ?? 'eql'), pageIndex: pageIndex || 0, pageSize, pageSizeOptions, @@ -542,13 +562,12 @@ export class EuiInMemoryTable extends Component< // search bar to ignore EQL syntax and only use the searchbar for plain text onPlainTextSearch = (searchValue: string) => { const escapedQueryText = searchValue.replace(/["\\]/g, '\\$&'); - const finalQuery = `"${escapedQueryText}"`; const { search } = this.props; if (isEuiSearchBarProps(search)) { if (search.onChange) { const shouldQueryInMemory = search.onChange({ - query: EuiSearchBar.Query.parse(finalQuery), + query: null, queryText: escapedQueryText, error: null, }); @@ -559,7 +578,7 @@ export class EuiInMemoryTable extends Component< } this.setState({ - query: EuiSearchBar.Query.parse(finalQuery), + query: `"${escapedQueryText}"`, }); }; @@ -570,13 +589,37 @@ export class EuiInMemoryTable extends Component< let searchBar: ReactNode; if (searchFormat === 'text') { - const _searchBoxProps = (search as EuiSearchBarProps)?.box || {}; // Work around | boolean type - const { schema, ...searchBoxProps } = _searchBoxProps; // Destructure `schema` so it doesn't get rendered to DOM + const { box = {}, query, defaultQuery } = search as EuiSearchBarProps; + const { + schema, // destructure `schema` so it doesn't get rendered to DOM + ...searchBoxProps + } = box; + + // in the unexpected case a Query object is passed with searchFormat=text + if (process.env.NODE_ENV === 'development') { + if (query != null && !isString(query)) { + console.warn( + 'EuiInMemoryTable: `query` should be a string when using searchFormat="text". Query objects are only supported with searchFormat="eql".' + ); + } + if (defaultQuery != null && !isString(defaultQuery)) { + console.warn( + 'EuiInMemoryTable: `defaultQuery` should be a string when using searchFormat="text". Query objects are only supported with searchFormat="eql".' + ); + } + } + + // use only string values, ignore Query objects + const displayQuery = isString(query) + ? query + : isString(defaultQuery) + ? defaultQuery + : ''; searchBar = ( ); diff --git a/packages/eui/src/components/search_bar/search_bar.tsx b/packages/eui/src/components/search_bar/search_bar.tsx index ed4f5da1ba1..c57ce764fd2 100644 --- a/packages/eui/src/components/search_bar/search_bar.tsx +++ b/packages/eui/src/components/search_bar/search_bar.tsx @@ -34,6 +34,16 @@ interface ArgsWithQuery { error: null; } +/** + * When `searchFormat` is 'text', `query` is null and the search is performed + * on the `queryText` directly without EQL parsing + */ +interface ArgsWithPlainText { + query: null; + queryText: string; + error: null; +} + interface ArgsWithError { query: null; queryText: string; @@ -48,7 +58,10 @@ export interface SchemaType { recognizedFields?: string[]; } -export type EuiSearchBarOnChangeArgs = ArgsWithQuery | ArgsWithError; +export type EuiSearchBarOnChangeArgs = + | ArgsWithQuery + | ArgsWithPlainText + | ArgsWithError; type HintPopOverProps = Partial< Pick< @@ -72,17 +85,17 @@ export interface EuiSearchBarProps extends CommonProps { onChange?: (args: EuiSearchBarOnChangeArgs) => void | boolean; /** - The initial query the bar will hold when first mounted + * The initial query the bar will hold when first mounted */ defaultQuery?: QueryType; /** - If you wish to use the search bar as a controlled component, continuously pass the query via this prop. + * If you wish to use the search bar as a controlled component, continuously pass the query via this prop. */ query?: QueryType; /** - Configures the search box. Set `placeholder` to change the placeholder text in the box and `incremental` to support incremental (as you type) search. + * Configures the search box. Set `placeholder` to change the placeholder text in the box and `incremental` to support incremental (as you type) search. */ box?: EuiFieldSearchProps & { // Boolean values are not meaningful to this EuiSearchBox, but are allowed so that other @@ -92,7 +105,7 @@ export interface EuiSearchBarProps extends CommonProps { }; /** - An array of search filters. See {@link SearchFilterConfig}. + * An array of search filters. See {@link SearchFilterConfig}. */ filters?: SearchFilterConfig[];