Skip to content
3 changes: 3 additions & 0 deletions packages/eui/changelogs/upcoming/9142.md
Original file line number Diff line number Diff line change
@@ -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`
105 changes: 104 additions & 1 deletion packages/eui/src/components/basic_table/in_memory_table.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1533,7 +1533,7 @@ describe('EuiInMemoryTable', () => {
);
expect(mockOnChange).toHaveBeenCalledTimes(1);
expect(mockOnChange).toHaveBeenCalledWith({
query: Query.parse(`"${TEXT}"`),
query: null,
queryText: TEXT,
error: null,
});
Expand Down Expand Up @@ -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(
<EuiInMemoryTable
items={items}
searchFormat="text"
search={{
query: CONTROLLED_QUERY,
box: { incremental: true, 'data-test-subj': 'searchbox' },
}}
columns={columns}
/>
);

const searchbox = getByTestSubject('searchbox') as HTMLInputElement;
expect(searchbox.value).toBe(CONTROLLED_QUERY);

// Update the controlled query
const UPDATED_QUERY = 'updated search';
rerender(
<EuiInMemoryTable
items={items}
searchFormat="text"
search={{
query: UPDATED_QUERY,
box: { incremental: true, 'data-test-subj': 'searchbox' },
}}
columns={columns}
/>
);

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(
<EuiInMemoryTable
items={items}
searchFormat="text"
search={{
defaultQuery: DEFAULT_QUERY,
box: { incremental: true, 'data-test-subj': 'searchbox' },
}}
columns={columns}
/>
);

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(
<EuiInMemoryTable
items={items}
searchFormat="text"
search={{
query,
box: { incremental: true, 'data-test-subj': 'searchbox' },
}}
columns={columns}
/>
);

const searchbox = getByTestSubject('searchbox') as HTMLInputElement;

// not `[object Object]`
expect(searchbox.value).toBe('');

rerender(
<EuiInMemoryTable
items={items}
searchFormat="text"
search={{
defaultQuery: query,
box: { incremental: true, 'data-test-subj': 'searchbox' },
}}
columns={columns}
/>
);

// not `[object Object]`
expect(searchbox.value).toBe('');
});
});
});
87 changes: 65 additions & 22 deletions packages/eui/src/components/basic_table/in_memory_table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,19 @@ type InMemoryTableProps<T extends object> = 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;
/**
* By default, tables use `eql` format for search which allows using advanced filters.
*
* 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).
Expand Down Expand Up @@ -159,7 +164,7 @@ interface State<T extends object> {
search?: Search;
};
search?: Search;
query: Query | null;
query: Query | string | null;
pageIndex: number;
pageSize?: number;
pageSizeOptions?: number[];
Expand All @@ -169,23 +174,34 @@ interface State<T extends object> {
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 = (
Expand Down Expand Up @@ -398,7 +414,11 @@ export class EuiInMemoryTable<T extends object = object> extends Component<
...updatedPrevState.prevProps,
search: nextProps.search,
},
query: getQueryFromSearch(nextProps.search, false),
query: getQueryFromSearch(
nextProps.search,
false,
nextProps.searchFormat ?? 'eql'
),
};
}
if (updatedPrevState !== prevState) {
Expand All @@ -423,7 +443,7 @@ export class EuiInMemoryTable<T extends object = object> extends Component<
search,
},
search: search,
query: getQueryFromSearch(search, true),
query: getQueryFromSearch(search, true, props.searchFormat ?? 'eql'),
pageIndex: pageIndex || 0,
pageSize,
pageSizeOptions,
Expand Down Expand Up @@ -542,13 +562,12 @@ export class EuiInMemoryTable<T extends object = object> 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,
});
Expand All @@ -559,7 +578,7 @@ export class EuiInMemoryTable<T extends object = object> extends Component<
}

this.setState({
query: EuiSearchBar.Query.parse(finalQuery),
query: `"${escapedQueryText}"`,
});
};

Expand All @@ -570,13 +589,37 @@ export class EuiInMemoryTable<T extends object = object> 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 = (
<EuiSearchBox
query="" // Unused, passed to satisfy Typescript
{...searchBoxProps}
query={displayQuery}
onSearch={this.onPlainTextSearch}
/>
);
Expand Down
23 changes: 18 additions & 5 deletions packages/eui/src/components/search_bar/search_bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -48,7 +58,10 @@ export interface SchemaType {
recognizedFields?: string[];
}

export type EuiSearchBarOnChangeArgs = ArgsWithQuery | ArgsWithError;
export type EuiSearchBarOnChangeArgs =
| ArgsWithQuery
| ArgsWithPlainText
| ArgsWithError;

type HintPopOverProps = Partial<
Pick<
Expand All @@ -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
Expand All @@ -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[];

Expand Down