Skip to content

Commit 2d95b7c

Browse files
MadameSheemaclaudekibanamachine
authored
Fix #245936 — shared exception list not refreshed after clearing search input (#270632)
## Summary Fixes: #245936 Shared exception lists were not refreshed after the user cleared a search term by backspacing, because `EuiSearchBar` with `incremental: false` only fires `onChange` on Enter or the built-in clear button — not on raw backspace keystrokes. This left `handleRefresh` using a stale `filters.name` value, so clicking Refresh returned filtered results instead of the full list. **Root cause**: `EuiSearchBar` with `incremental: false` does not fire `onChange` on every keystroke. Raw backspace input is invisible to React state, so `filters.name` could hold a stale search term even after the input appeared empty. **Fix**: Added an `onInputChange` prop to `ListsSearchBar` using a native `onInput` handler on a wrapper `<div>` to capture raw DOM keystrokes. In `SharedLists`, a `useRef` (`rawSearchInputRef`) tracks the current raw input value without causing re-renders. `handleRefresh` now checks: if the raw input is empty but `filters` still has a name, it calls `setFilters(undefined)` to clear the stale filter and trigger a clean re-fetch. **Why this approach**: `onInput` fires on every keystroke (including backspace), unlike `onChange` which only fires on Enter/clear in non-incremental mode. Using a `ref` avoids unnecessary re-renders on every keystroke while still providing `handleRefresh` with an up-to-date snapshot of the input value. ## Steps to reproduce (before fix) 1. Navigate to Security → Manage → Shared Exception Lists 2. Type a search term in the search bar and press Enter — the list filters 3. Clear the search term by pressing Backspace (without pressing Enter) 4. Click the Refresh button 5. **Expected**: Full list is shown 6. **Actual**: Filtered results remain (stale filter applied) ## Test plan - [ ] Bug no longer reproduces: follow the steps above and verify the full list appears after clearing and refreshing - [ ] `node scripts/jest x-pack/solutions/security/plugins/security_solution/public/exceptions/components/list_search_bar/index.test.tsx --no-coverage` passes - [ ] `node scripts/jest x-pack/solutions/security/plugins/security_solution/public/exceptions/pages/shared_lists/shared_lists.test.tsx --no-coverage` passes - [ ] No new browser console errors or network failures ## Related Fixes #245936 🤖 Generated with [Claude Code](https://claude.ai/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
1 parent b8ae714 commit 2d95b7c

4 files changed

Lines changed: 120 additions & 18 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import React from 'react';
9+
import { render, fireEvent } from '@testing-library/react';
10+
import { TestProviders } from '../../../common/mock';
11+
import { ListsSearchBar } from '.';
12+
13+
describe('ListsSearchBar', () => {
14+
it('calls onInputChange with the typed value when user types in the search input', () => {
15+
const onInputChange = jest.fn();
16+
17+
const { getByTestId } = render(
18+
<TestProviders>
19+
<ListsSearchBar onSearch={jest.fn()} onInputChange={onInputChange} />
20+
</TestProviders>
21+
);
22+
23+
const input = getByTestId('exceptionsHeaderSearchInput');
24+
fireEvent.input(input, { target: { value: 'foo' } });
25+
26+
expect(onInputChange).toHaveBeenCalledWith('foo');
27+
});
28+
29+
it('calls onInputChange with empty string when user clears the input', () => {
30+
const onInputChange = jest.fn();
31+
32+
const { getByTestId } = render(
33+
<TestProviders>
34+
<ListsSearchBar onSearch={jest.fn()} onInputChange={onInputChange} />
35+
</TestProviders>
36+
);
37+
38+
const input = getByTestId('exceptionsHeaderSearchInput');
39+
fireEvent.input(input, { target: { value: 'foo' } });
40+
fireEvent.input(input, { target: { value: '' } });
41+
42+
expect(onInputChange).toHaveBeenCalledTimes(2);
43+
expect(onInputChange).toHaveBeenLastCalledWith('');
44+
});
45+
46+
it('does not throw when onInputChange is not provided', () => {
47+
const { getByTestId } = render(
48+
<TestProviders>
49+
<ListsSearchBar onSearch={jest.fn()} />
50+
</TestProviders>
51+
);
52+
53+
const input = getByTestId('exceptionsHeaderSearchInput');
54+
expect(() => {
55+
fireEvent.input(input, { target: { value: 'foo' } });
56+
}).not.toThrow();
57+
});
58+
});

x-pack/solutions/security/plugins/security_solution/public/exceptions/components/list_search_bar/index.tsx

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import * as i18n from '../../translations';
1313

1414
interface ExceptionListsTableSearchProps {
1515
onSearch: (args: Parameters<NonNullable<EuiSearchBarProps['onChange']>>[0]) => void;
16+
onInputChange?: (value: string) => void;
1617
}
1718

1819
// TODO replace this component with the @Kbn/securitysolution-exception-list-components
@@ -37,20 +38,24 @@ export const EXCEPTIONS_SEARCH_SCHEMA = {
3738
},
3839
};
3940

40-
export const ListsSearchBar = React.memo<ExceptionListsTableSearchProps>(({ onSearch }) => {
41-
return (
42-
<EuiSearchBar
43-
data-test-subj="exceptionsHeaderSearch"
44-
aria-label={i18n.EXCEPTIONS_LISTS_SEARCH_PLACEHOLDER}
45-
onChange={onSearch}
46-
box={{
47-
[`data-test-subj`]: 'exceptionsHeaderSearchInput',
48-
placeholder: i18n.EXCEPTION_LIST_SEARCH_PLACEHOLDER,
49-
incremental: false,
50-
schema: EXCEPTIONS_SEARCH_SCHEMA,
51-
}}
52-
/>
53-
);
54-
});
41+
export const ListsSearchBar = React.memo<ExceptionListsTableSearchProps>(
42+
({ onSearch, onInputChange }) => {
43+
return (
44+
<div onInput={(e) => onInputChange?.((e.target as HTMLInputElement).value)}>
45+
<EuiSearchBar
46+
data-test-subj="exceptionsHeaderSearch"
47+
aria-label={i18n.EXCEPTIONS_LISTS_SEARCH_PLACEHOLDER}
48+
onChange={onSearch}
49+
box={{
50+
[`data-test-subj`]: 'exceptionsHeaderSearchInput',
51+
placeholder: i18n.EXCEPTION_LIST_SEARCH_PLACEHOLDER,
52+
incremental: false,
53+
schema: EXCEPTIONS_SEARCH_SCHEMA,
54+
}}
55+
/>
56+
</div>
57+
);
58+
}
59+
);
5560

5661
ListsSearchBar.displayName = 'ListsSearchBar';

x-pack/solutions/security/plugins/security_solution/public/exceptions/pages/shared_lists/index.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ export const SharedLists = React.memo(() => {
116116
exceptionReferenceModalInitialState
117117
);
118118
const [filters, setFilters] = useState<ExceptionListFilter | undefined>();
119+
const rawSearchInputRef = useRef('');
119120

120121
const [viewerStatus, setViewStatus] = useState<ViewerStatus | null>(ViewerStatus.LOADING);
121122

@@ -268,12 +269,20 @@ export const SharedLists = React.memo(() => {
268269
[exportExceptionList, handleExportError, handleExportSuccess]
269270
);
270271

272+
const handleInputChange = useCallback((value: string): void => {
273+
rawSearchInputRef.current = value;
274+
}, []);
275+
271276
const handleRefresh = useCallback((): void => {
272277
if (refreshExceptions != null) {
273278
setLastUpdated(Date.now());
274-
refreshExceptions();
279+
if (!rawSearchInputRef.current && filters) {
280+
setFilters(undefined);
281+
} else {
282+
refreshExceptions();
283+
}
275284
}
276-
}, [refreshExceptions]);
285+
}, [refreshExceptions, filters]);
277286

278287
useEffect(() => {
279288
if (initLoading && !loading && !loadingExceptions && !loadingTableInfo) {
@@ -630,7 +639,9 @@ export const SharedLists = React.memo(() => {
630639
<EndpointExceptionsMovedCallout id="sharedListsPage" dismissable title="moved" />
631640
)}
632641

633-
{!initLoading && <ListsSearchBar onSearch={handleSearch} />}
642+
{!initLoading && (
643+
<ListsSearchBar onSearch={handleSearch} onInputChange={handleInputChange} />
644+
)}
634645
<EuiSpacer size="m" />
635646
{viewerStatus != null ? (
636647
<EmptyViewerState

x-pack/solutions/security/plugins/security_solution/public/exceptions/pages/shared_lists/shared_lists.test.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,34 @@ describe('SharedLists', () => {
455455
});
456456
});
457457

458+
it('calls refreshExceptions via Refresh button when no stale filter is present', async () => {
459+
const mockRefreshExceptions = jest.fn();
460+
461+
(useExceptionLists as jest.Mock).mockReturnValue([
462+
false,
463+
[exceptionList1, exceptionList2],
464+
{ page: 1, perPage: 20, total: 2 },
465+
jest.fn(),
466+
mockRefreshExceptions,
467+
{ field: 'created_at', order: 'desc' },
468+
jest.fn(),
469+
]);
470+
471+
const { getByTestId } = render(
472+
<TestProviders>
473+
<SharedLists />
474+
</TestProviders>
475+
);
476+
477+
await waitFor(() => {
478+
expect(getByTestId('refreshRulesAction-linkIcon')).toBeInTheDocument();
479+
});
480+
481+
fireEvent.click(getByTestId('refreshRulesAction-linkIcon'));
482+
483+
expect(mockRefreshExceptions).toHaveBeenCalledTimes(1);
484+
});
485+
458486
it('returns focus to the create button when the create shared list flyout is closed', async () => {
459487
(useUserPrivileges as jest.Mock).mockReturnValue({
460488
...initialUserPrivilegesState(),

0 commit comments

Comments
 (0)