mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[Global search] Prevent unnecessary /internal/global_search/find http call at startup (#112535)
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
aba80059ce
commit
9e7f1c8e35
3 changed files with 148 additions and 175 deletions
|
@ -1,44 +0,0 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`SearchBar correctly filters and sorts results 1`] = `
|
||||
Array [
|
||||
"Canvas • Kibana",
|
||||
"Discover • Kibana",
|
||||
"Graph • Kibana",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`SearchBar correctly filters and sorts results 2`] = `
|
||||
Array [
|
||||
"Discover • Kibana",
|
||||
"My Dashboard • Test",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`SearchBar only display results from the last search 1`] = `
|
||||
Array [
|
||||
"Visualize • Kibana",
|
||||
"Map • Kibana",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`SearchBar only display results from the last search 2`] = `
|
||||
Array [
|
||||
"Visualize • Kibana",
|
||||
"Map • Kibana",
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`SearchBar supports keyboard shortcuts 1`] = `
|
||||
<input
|
||||
aria-activedescendant=""
|
||||
aria-haspopup="listbox"
|
||||
aria-label="Search Elastic"
|
||||
autocomplete="off"
|
||||
class="euiFieldSearch euiFieldSearch--fullWidth euiFieldSearch--compressed euiSelectableSearch euiSelectableTemplateSitewide__search kbnSearchBar"
|
||||
data-test-subj="nav-search-input"
|
||||
placeholder="Search Elastic"
|
||||
type="search"
|
||||
value=""
|
||||
/>
|
||||
`;
|
|
@ -6,15 +6,21 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { waitFor, act } from '@testing-library/react';
|
||||
import { ReactWrapper } from 'enzyme';
|
||||
import { act, render, screen, fireEvent } from '@testing-library/react';
|
||||
import { of, BehaviorSubject } from 'rxjs';
|
||||
import { filter, map } from 'rxjs/operators';
|
||||
import { mountWithIntl } from '@kbn/test/jest';
|
||||
import { applicationServiceMock } from '../../../../../src/core/public/mocks';
|
||||
import { globalSearchPluginMock } from '../../../global_search/public/mocks';
|
||||
import { GlobalSearchBatchedResults, GlobalSearchResult } from '../../../global_search/public';
|
||||
import { SearchBar } from './search_bar';
|
||||
import { __IntlProvider as IntlProvider } from '@kbn/i18n/react';
|
||||
|
||||
jest.mock(
|
||||
'react-virtualized-auto-sizer',
|
||||
() =>
|
||||
({ children }: any) =>
|
||||
children({ height: 600, width: 600 })
|
||||
);
|
||||
|
||||
type Result = { id: string; type: string } | string;
|
||||
|
||||
|
@ -36,9 +42,7 @@ const createResult = (result: Result): GlobalSearchResult => {
|
|||
const createBatch = (...results: Result[]): GlobalSearchBatchedResults => ({
|
||||
results: results.map(createResult),
|
||||
});
|
||||
|
||||
const getSelectableProps: any = (component: any) => component.find('EuiSelectable').props();
|
||||
const getSearchProps: any = (component: any) => component.find('EuiFieldSearch').props();
|
||||
jest.useFakeTimers();
|
||||
|
||||
describe('SearchBar', () => {
|
||||
let searchService: ReturnType<typeof globalSearchPluginMock.createStartContract>;
|
||||
|
@ -46,31 +50,37 @@ describe('SearchBar', () => {
|
|||
const basePathUrl = '/plugins/globalSearchBar/assets/';
|
||||
const darkMode = false;
|
||||
|
||||
let component: ReactWrapper<any>;
|
||||
|
||||
beforeEach(() => {
|
||||
applications = applicationServiceMock.createStartContract();
|
||||
searchService = globalSearchPluginMock.createStartContract();
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
const triggerFocus = () => {
|
||||
component.find('input[data-test-subj="nav-search-input"]').simulate('focus');
|
||||
};
|
||||
|
||||
const update = () => {
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
component.update();
|
||||
};
|
||||
|
||||
const simulateTypeChar = async (text: string) => {
|
||||
await waitFor(() => getSearchProps(component).onInput({ currentTarget: { value: text } }));
|
||||
const focusAndUpdate = async () => {
|
||||
await act(async () => {
|
||||
(await screen.findByTestId('nav-search-input')).focus();
|
||||
jest.runAllTimers();
|
||||
});
|
||||
};
|
||||
|
||||
const getDisplayedOptionsTitle = () => {
|
||||
return getSelectableProps(component).options.map((option: any) => option.title);
|
||||
const simulateTypeChar = (text: string) => {
|
||||
fireEvent.input(screen.getByTestId('nav-search-input'), { target: { value: text } });
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
};
|
||||
|
||||
const assertSearchResults = async (list: string[]) => {
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
expect(await screen.findByTitle(list[i])).toBeInTheDocument();
|
||||
}
|
||||
|
||||
expect(await screen.findAllByTestId('nav-search-option')).toHaveLength(list.length);
|
||||
};
|
||||
|
||||
it('correctly filters and sorts results', async () => {
|
||||
|
@ -83,53 +93,52 @@ describe('SearchBar', () => {
|
|||
)
|
||||
.mockReturnValueOnce(of(createBatch('Discover', { id: 'My Dashboard', type: 'test' })));
|
||||
|
||||
component = mountWithIntl(
|
||||
<SearchBar
|
||||
globalSearch={searchService}
|
||||
navigateToUrl={applications.navigateToUrl}
|
||||
basePathUrl={basePathUrl}
|
||||
darkMode={darkMode}
|
||||
trackUiMetric={jest.fn()}
|
||||
/>
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<SearchBar
|
||||
globalSearch={searchService}
|
||||
navigateToUrl={applications.navigateToUrl}
|
||||
basePathUrl={basePathUrl}
|
||||
darkMode={darkMode}
|
||||
trackUiMetric={jest.fn()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
expect(searchService.find).toHaveBeenCalledTimes(0);
|
||||
|
||||
triggerFocus();
|
||||
update();
|
||||
await focusAndUpdate();
|
||||
|
||||
expect(searchService.find).toHaveBeenCalledTimes(1);
|
||||
expect(searchService.find).toHaveBeenCalledWith({}, {});
|
||||
expect(getDisplayedOptionsTitle()).toMatchSnapshot();
|
||||
await assertSearchResults(['Canvas • Kibana', 'Discover • Kibana', 'Graph • Kibana']);
|
||||
|
||||
await simulateTypeChar('d');
|
||||
update();
|
||||
simulateTypeChar('d');
|
||||
|
||||
expect(getDisplayedOptionsTitle()).toMatchSnapshot();
|
||||
await assertSearchResults(['Discover • Kibana', 'My Dashboard • Test']);
|
||||
expect(searchService.find).toHaveBeenCalledTimes(2);
|
||||
expect(searchService.find).toHaveBeenCalledWith({ term: 'd' }, {});
|
||||
expect(searchService.find).toHaveBeenLastCalledWith({ term: 'd' }, {});
|
||||
});
|
||||
|
||||
it('supports keyboard shortcuts', () => {
|
||||
mountWithIntl(
|
||||
<SearchBar
|
||||
globalSearch={searchService}
|
||||
navigateToUrl={applications.navigateToUrl}
|
||||
basePathUrl={basePathUrl}
|
||||
darkMode={darkMode}
|
||||
trackUiMetric={jest.fn()}
|
||||
/>,
|
||||
{ attachTo: document.body }
|
||||
it('supports keyboard shortcuts', async () => {
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<SearchBar
|
||||
globalSearch={searchService}
|
||||
navigateToUrl={applications.navigateToUrl}
|
||||
basePathUrl={basePathUrl}
|
||||
darkMode={darkMode}
|
||||
trackUiMetric={jest.fn()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
);
|
||||
act(() => {
|
||||
fireEvent.keyDown(window, { key: '/', ctrlKey: true, metaKey: true });
|
||||
});
|
||||
|
||||
const searchEvent = new KeyboardEvent('keydown', {
|
||||
key: '/',
|
||||
ctrlKey: true,
|
||||
metaKey: true,
|
||||
} as any);
|
||||
window.dispatchEvent(searchEvent);
|
||||
const inputElement = await screen.findByTestId('nav-search-input');
|
||||
|
||||
expect(document.activeElement).toMatchSnapshot();
|
||||
expect(document.activeElement).toEqual(inputElement);
|
||||
});
|
||||
|
||||
it('only display results from the last search', async () => {
|
||||
|
@ -144,30 +153,29 @@ describe('SearchBar', () => {
|
|||
|
||||
searchService.find.mockReturnValueOnce(firstSearch).mockReturnValueOnce(secondSearch);
|
||||
|
||||
component = mountWithIntl(
|
||||
<SearchBar
|
||||
globalSearch={searchService}
|
||||
navigateToUrl={applications.navigateToUrl}
|
||||
basePathUrl={basePathUrl}
|
||||
darkMode={darkMode}
|
||||
trackUiMetric={jest.fn()}
|
||||
/>
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<SearchBar
|
||||
globalSearch={searchService}
|
||||
navigateToUrl={applications.navigateToUrl}
|
||||
basePathUrl={basePathUrl}
|
||||
darkMode={darkMode}
|
||||
trackUiMetric={jest.fn()}
|
||||
/>
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
triggerFocus();
|
||||
update();
|
||||
await focusAndUpdate();
|
||||
|
||||
expect(searchService.find).toHaveBeenCalledTimes(1);
|
||||
|
||||
await simulateTypeChar('d');
|
||||
update();
|
||||
|
||||
expect(getDisplayedOptionsTitle()).toMatchSnapshot();
|
||||
//
|
||||
simulateTypeChar('d');
|
||||
await assertSearchResults(['Visualize • Kibana', 'Map • Kibana']);
|
||||
|
||||
firstSearchTrigger.next(true);
|
||||
|
||||
update();
|
||||
|
||||
expect(getDisplayedOptionsTitle()).toMatchSnapshot();
|
||||
await assertSearchResults(['Visualize • Kibana', 'Map • Kibana']);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -180,6 +180,7 @@ export function SearchBar({
|
|||
darkMode,
|
||||
}: Props) {
|
||||
const isMounted = useMountedState();
|
||||
const [initialLoad, setInitialLoad] = useState(false);
|
||||
const [searchValue, setSearchValue] = useState<string>('');
|
||||
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||
const [searchRef, setSearchRef] = useState<HTMLInputElement | null>(null);
|
||||
|
@ -190,12 +191,14 @@ export function SearchBar({
|
|||
const UNKNOWN_TAG_ID = '__unknown__';
|
||||
|
||||
useEffect(() => {
|
||||
const fetch = async () => {
|
||||
const types = await globalSearch.getSearchableTypes();
|
||||
setSearchableTypes(types);
|
||||
};
|
||||
fetch();
|
||||
}, [globalSearch]);
|
||||
if (initialLoad) {
|
||||
const fetch = async () => {
|
||||
const types = await globalSearch.getSearchableTypes();
|
||||
setSearchableTypes(types);
|
||||
};
|
||||
fetch();
|
||||
}
|
||||
}, [globalSearch, initialLoad]);
|
||||
|
||||
const loadSuggestions = useCallback(
|
||||
(term: string) => {
|
||||
|
@ -234,75 +237,80 @@ export function SearchBar({
|
|||
|
||||
useDebounce(
|
||||
() => {
|
||||
// cancel pending search if not completed yet
|
||||
if (searchSubscription.current) {
|
||||
searchSubscription.current.unsubscribe();
|
||||
searchSubscription.current = null;
|
||||
}
|
||||
if (initialLoad) {
|
||||
// cancel pending search if not completed yet
|
||||
if (searchSubscription.current) {
|
||||
searchSubscription.current.unsubscribe();
|
||||
searchSubscription.current = null;
|
||||
}
|
||||
|
||||
const suggestions = loadSuggestions(searchValue);
|
||||
const suggestions = loadSuggestions(searchValue);
|
||||
|
||||
let aggregatedResults: GlobalSearchResult[] = [];
|
||||
if (searchValue.length !== 0) {
|
||||
trackUiMetric(METRIC_TYPE.COUNT, 'search_request');
|
||||
}
|
||||
let aggregatedResults: GlobalSearchResult[] = [];
|
||||
if (searchValue.length !== 0) {
|
||||
trackUiMetric(METRIC_TYPE.COUNT, 'search_request');
|
||||
}
|
||||
|
||||
const rawParams = parseSearchParams(searchValue);
|
||||
const tagIds =
|
||||
taggingApi && rawParams.filters.tags
|
||||
? rawParams.filters.tags.map(
|
||||
(tagName) => taggingApi.ui.getTagIdFromName(tagName) ?? UNKNOWN_TAG_ID
|
||||
)
|
||||
: undefined;
|
||||
const searchParams: GlobalSearchFindParams = {
|
||||
term: rawParams.term,
|
||||
types: rawParams.filters.types,
|
||||
tags: tagIds,
|
||||
};
|
||||
// TODO technically a subtle bug here
|
||||
// this term won't be set until the next time the debounce is fired
|
||||
// so the SearchOption won't highlight anything if only one call is fired
|
||||
// in practice, this is hard to spot, unlikely to happen, and is a negligible issue
|
||||
setSearchTerm(rawParams.term ?? '');
|
||||
const rawParams = parseSearchParams(searchValue);
|
||||
const tagIds =
|
||||
taggingApi && rawParams.filters.tags
|
||||
? rawParams.filters.tags.map(
|
||||
(tagName) => taggingApi.ui.getTagIdFromName(tagName) ?? UNKNOWN_TAG_ID
|
||||
)
|
||||
: undefined;
|
||||
const searchParams: GlobalSearchFindParams = {
|
||||
term: rawParams.term,
|
||||
types: rawParams.filters.types,
|
||||
tags: tagIds,
|
||||
};
|
||||
// TODO technically a subtle bug here
|
||||
// this term won't be set until the next time the debounce is fired
|
||||
// so the SearchOption won't highlight anything if only one call is fired
|
||||
// in practice, this is hard to spot, unlikely to happen, and is a negligible issue
|
||||
setSearchTerm(rawParams.term ?? '');
|
||||
|
||||
searchSubscription.current = globalSearch.find(searchParams, {}).subscribe({
|
||||
next: ({ results }) => {
|
||||
if (searchValue.length > 0) {
|
||||
aggregatedResults = [...results, ...aggregatedResults].sort(sortByScore);
|
||||
setOptions(aggregatedResults, suggestions, searchParams.tags);
|
||||
return;
|
||||
}
|
||||
|
||||
// if searchbar is empty, filter to only applications and sort alphabetically
|
||||
results = results.filter(({ type }: GlobalSearchResult) => type === 'application');
|
||||
|
||||
aggregatedResults = [...results, ...aggregatedResults].sort(sortByTitle);
|
||||
|
||||
searchSubscription.current = globalSearch.find(searchParams, {}).subscribe({
|
||||
next: ({ results }) => {
|
||||
if (searchValue.length > 0) {
|
||||
aggregatedResults = [...results, ...aggregatedResults].sort(sortByScore);
|
||||
setOptions(aggregatedResults, suggestions, searchParams.tags);
|
||||
return;
|
||||
}
|
||||
|
||||
// if searchbar is empty, filter to only applications and sort alphabetically
|
||||
results = results.filter(({ type }: GlobalSearchResult) => type === 'application');
|
||||
|
||||
aggregatedResults = [...results, ...aggregatedResults].sort(sortByTitle);
|
||||
|
||||
setOptions(aggregatedResults, suggestions, searchParams.tags);
|
||||
},
|
||||
error: () => {
|
||||
// Not doing anything on error right now because it'll either just show the previous
|
||||
// results or empty results which is basically what we want anyways
|
||||
trackUiMetric(METRIC_TYPE.COUNT, 'unhandled_error');
|
||||
},
|
||||
complete: () => {},
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
// Not doing anything on error right now because it'll either just show the previous
|
||||
// results or empty results which is basically what we want anyways
|
||||
trackUiMetric(METRIC_TYPE.COUNT, 'unhandled_error');
|
||||
},
|
||||
complete: () => {},
|
||||
});
|
||||
}
|
||||
},
|
||||
350,
|
||||
[searchValue, loadSuggestions]
|
||||
[searchValue, loadSuggestions, searchableTypes, initialLoad]
|
||||
);
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === '/' && (isMac ? event.metaKey : event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
trackUiMetric(METRIC_TYPE.COUNT, 'shortcut_used');
|
||||
if (searchRef) {
|
||||
searchRef.focus();
|
||||
} else if (buttonRef) {
|
||||
(buttonRef.children[0] as HTMLButtonElement).click();
|
||||
const onKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key === '/' && (isMac ? event.metaKey : event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
trackUiMetric(METRIC_TYPE.COUNT, 'shortcut_used');
|
||||
if (searchRef) {
|
||||
searchRef.focus();
|
||||
} else if (buttonRef) {
|
||||
(buttonRef.children[0] as HTMLButtonElement).click();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
[buttonRef, searchRef, trackUiMetric]
|
||||
);
|
||||
|
||||
const onChange = (selection: EuiSelectableTemplateSitewideOption[]) => {
|
||||
const selected = selection.find(({ checked }) => checked === 'on');
|
||||
|
@ -411,6 +419,7 @@ export function SearchBar({
|
|||
}),
|
||||
onFocus: () => {
|
||||
trackUiMetric(METRIC_TYPE.COUNT, 'search_focus');
|
||||
setInitialLoad(true);
|
||||
},
|
||||
}}
|
||||
popoverProps={{
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue