[Global search] fix navigating app name (#159072)

## Summary

Telemetry data for global search showed that all applications navigated
to via global search were identified as `undefined`.

## Other changes
This PR does some light refactoring in
`x-pack/plugins/global_search_bar/public/components` in preparation for
further work in https://github.com/elastic/kibana/issues/158874 and
https://github.com/elastic/kibana/issues/158879

### Checklist
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
This commit is contained in:
Tim Sullivan 2023-06-07 10:26:12 -07:00 committed by GitHub
parent 031c237439
commit 6383f45ed0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 236 additions and 114 deletions

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { GlobalSearchResult } from '@kbn/global-search-plugin/public';
/* @internal */
export enum COUNT_METRIC {
UNHANDLED_ERROR = 'unhandled_error',
SEARCH_REQUEST = 'search_request',
SHORTCUT_USED = 'shortcut_used',
SEARCH_FOCUS = 'search_focus',
}
/* @internal */
export enum CLICK_METRIC {
USER_NAVIGATED_TO_APPLICATION = 'user_navigated_to_application',
USER_NAVIGATED_TO_SAVED_OBJECT = 'user_navigated_to_saved_object',
}
/* @internal */
export const getClickMetric = (metric: CLICK_METRIC, context: string) => {
return [metric, `${metric}_${context}`];
};
/* @internal */
export const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0;
/* @internal */
export const blurEvent = new FocusEvent('focusout', {
bubbles: true,
});
const sortByScore = (a: GlobalSearchResult, b: GlobalSearchResult): number => {
if (a.score < b.score) return 1;
if (a.score > b.score) return -1;
return 0;
};
const sortByTitle = (a: GlobalSearchResult, b: GlobalSearchResult): number => {
const titleA = a.title.toUpperCase(); // ignore upper and lowercase
const titleB = b.title.toUpperCase(); // ignore upper and lowercase
if (titleA < titleB) return -1;
if (titleA > titleB) return 1;
return 0;
};
/* @internal */
export const sort = {
byScore: sortByScore,
byTitle: sortByTitle,
};

View file

@ -14,6 +14,7 @@ import { globalSearchPluginMock } from '@kbn/global-search-plugin/public/mocks';
import { GlobalSearchBatchedResults, GlobalSearchResult } from '@kbn/global-search-plugin/public';
import { SearchBar } from './search_bar';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import { TrackUiMetricFn } from '../types';
jest.mock(
'react-virtualized-auto-sizer',
@ -47,12 +48,15 @@ jest.useFakeTimers({ legacyFakeTimers: true });
describe('SearchBar', () => {
let searchService: ReturnType<typeof globalSearchPluginMock.createStartContract>;
let applications: ReturnType<typeof applicationServiceMock.createStartContract>;
let trackUiMetric: TrackUiMetricFn;
const basePathUrl = '/plugins/globalSearchBar/assets/';
const darkMode = false;
beforeEach(() => {
applications = applicationServiceMock.createStartContract();
searchService = globalSearchPluginMock.createStartContract();
trackUiMetric = jest.fn();
});
const update = () => {
@ -100,7 +104,7 @@ describe('SearchBar', () => {
navigateToUrl={applications.navigateToUrl}
basePathUrl={basePathUrl}
darkMode={darkMode}
trackUiMetric={jest.fn()}
trackUiMetric={trackUiMetric}
/>
</IntlProvider>
);
@ -118,6 +122,10 @@ describe('SearchBar', () => {
await assertSearchResults(['Discover • Kibana', 'My Dashboard • Test']);
expect(searchService.find).toHaveBeenCalledTimes(2);
expect(searchService.find).toHaveBeenLastCalledWith({ term: 'd' }, {});
expect(trackUiMetric).nthCalledWith(1, 'count', 'search_focus');
expect(trackUiMetric).nthCalledWith(2, 'count', 'search_request');
expect(trackUiMetric).toHaveBeenCalledTimes(2);
});
it('supports keyboard shortcuts', async () => {
@ -128,7 +136,7 @@ describe('SearchBar', () => {
navigateToUrl={applications.navigateToUrl}
basePathUrl={basePathUrl}
darkMode={darkMode}
trackUiMetric={jest.fn()}
trackUiMetric={trackUiMetric}
/>
</IntlProvider>
);
@ -139,6 +147,10 @@ describe('SearchBar', () => {
const inputElement = await screen.findByTestId('nav-search-input');
expect(document.activeElement).toEqual(inputElement);
expect(trackUiMetric).nthCalledWith(1, 'count', 'shortcut_used');
expect(trackUiMetric).nthCalledWith(2, 'count', 'search_focus');
expect(trackUiMetric).toHaveBeenCalledTimes(2);
});
it('only display results from the last search', async () => {
@ -160,7 +172,7 @@ describe('SearchBar', () => {
navigateToUrl={applications.navigateToUrl}
basePathUrl={basePathUrl}
darkMode={darkMode}
trackUiMetric={jest.fn()}
trackUiMetric={trackUiMetric}
/>
</IntlProvider>
);
@ -178,4 +190,42 @@ describe('SearchBar', () => {
await assertSearchResults(['Visualize • Kibana', 'Map • Kibana']);
});
it('tracks the application navigated to', async () => {
searchService.find.mockReturnValueOnce(
of(createBatch('Discover', { id: 'My Dashboard', type: 'test' }))
);
render(
<IntlProvider locale="en">
<SearchBar
globalSearch={searchService}
navigateToUrl={applications.navigateToUrl}
basePathUrl={basePathUrl}
darkMode={darkMode}
trackUiMetric={trackUiMetric}
/>
</IntlProvider>
);
expect(searchService.find).toHaveBeenCalledTimes(0);
await focusAndUpdate();
expect(searchService.find).toHaveBeenCalledTimes(1);
expect(searchService.find).toHaveBeenCalledWith({}, {});
await assertSearchResults(['Discover • Kibana']);
const navSearchOptionToClick = await screen.findByTestId('nav-search-option');
act(() => {
fireEvent.click(navSearchOptionToClick);
});
expect(trackUiMetric).nthCalledWith(1, 'count', 'search_focus');
expect(trackUiMetric).nthCalledWith(2, 'click', [
'user_navigated_to_application',
'user_navigated_to_application_discover',
]);
expect(trackUiMetric).toHaveBeenCalledTimes(2);
});
});

View file

@ -5,75 +5,53 @@
* 2.0.
*/
import React, { FC, useCallback, useRef, useState, useEffect } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiFormLabel,
EuiHeaderSectionItemButton,
EuiIcon,
EuiLoadingSpinner,
EuiSelectableTemplateSitewide,
EuiSelectableTemplateSitewideOption,
euiSelectableTemplateSitewideRenderOptions,
useEuiTheme,
} from '@elastic/eui';
import { METRIC_TYPE } from '@kbn/analytics';
import type { GlobalSearchFindParams, GlobalSearchResult } from '@kbn/global-search-plugin/public';
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
import useDebounce from 'react-use/lib/useDebounce';
import useEvent from 'react-use/lib/useEvent';
import useMountedState from 'react-use/lib/useMountedState';
import { Subscription } from 'rxjs';
import {
useEuiTheme,
EuiFormLabel,
EuiHeaderSectionItemButton,
EuiIcon,
EuiSelectableTemplateSitewide,
EuiSelectableTemplateSitewideOption,
euiSelectableTemplateSitewideRenderOptions,
EuiLoadingSpinner,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import { METRIC_TYPE, UiCounterMetricType } from '@kbn/analytics';
import { i18n } from '@kbn/i18n';
import type { ApplicationStart } from '@kbn/core/public';
import type {
GlobalSearchPluginStart,
GlobalSearchResult,
GlobalSearchFindParams,
} from '@kbn/global-search-plugin/public';
import type { SavedObjectTaggingPluginStart } from '@kbn/saved-objects-tagging-plugin/public';
import { parseSearchParams } from '../search_syntax';
import { getSuggestions, SearchSuggestion } from '../suggestions';
import { blurEvent, CLICK_METRIC, COUNT_METRIC, getClickMetric, isMac, sort } from '.';
import { resultToOption, suggestionToOption } from '../lib';
import { parseSearchParams } from '../search_syntax';
import { i18nStrings } from '../strings';
import { getSuggestions, SearchSuggestion } from '../suggestions';
import { PopoverFooter } from './popover_footer';
import { PopoverPlaceholder } from './popover_placeholder';
import './search_bar.scss';
import { SearchBarProps } from './types';
const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0;
const NoMatchesMessage = (props: { basePathUrl: string; darkMode: boolean }) => (
<PopoverPlaceholder darkMode={props.darkMode} basePath={props.basePathUrl} />
);
const blurEvent = new FocusEvent('focusout', {
bubbles: true,
});
const sortByScore = (a: GlobalSearchResult, b: GlobalSearchResult): number => {
if (a.score < b.score) return 1;
if (a.score > b.score) return -1;
return 0;
};
const sortByTitle = (a: GlobalSearchResult, b: GlobalSearchResult): number => {
const titleA = a.title.toUpperCase(); // ignore upper and lowercase
const titleB = b.title.toUpperCase(); // ignore upper and lowercase
if (titleA < titleB) return -1;
if (titleA > titleB) return 1;
return 0;
};
interface SearchBarProps {
globalSearch: GlobalSearchPluginStart;
navigateToUrl: ApplicationStart['navigateToUrl'];
trackUiMetric: (metricType: UiCounterMetricType, eventName: string | string[]) => void;
taggingApi?: SavedObjectTaggingPluginStart;
basePathUrl: string;
darkMode: boolean;
}
const EmptyMessage = () => (
<EuiFlexGroup direction="column" justifyContent="center" style={{ minHeight: '300px' }}>
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="xl" />
</EuiFlexItem>
</EuiFlexGroup>
);
export const SearchBar: FC<SearchBarProps> = ({
globalSearch,
taggingApi,
navigateToUrl,
trackUiMetric,
basePathUrl,
darkMode,
...props
}) => {
const isMounted = useMountedState();
const { euiTheme } = useEuiTheme();
@ -146,7 +124,7 @@ export const SearchBar: FC<SearchBarProps> = ({
let aggregatedResults: GlobalSearchResult[] = [];
if (searchValue.length !== 0) {
trackUiMetric(METRIC_TYPE.COUNT, 'search_request');
trackUiMetric(METRIC_TYPE.COUNT, COUNT_METRIC.SEARCH_REQUEST);
}
const rawParams = parseSearchParams(searchValue);
@ -170,7 +148,7 @@ export const SearchBar: FC<SearchBarProps> = ({
searchSubscription.current = globalSearch.find(searchParams, {}).subscribe({
next: ({ results }) => {
if (searchValue.length > 0) {
aggregatedResults = [...results, ...aggregatedResults].sort(sortByScore);
aggregatedResults = [...results, ...aggregatedResults].sort(sort.byScore);
setOptions(aggregatedResults, suggestions, searchParams.tags);
return;
}
@ -178,14 +156,14 @@ export const SearchBar: FC<SearchBarProps> = ({
// if searchbar is empty, filter to only applications and sort alphabetically
results = results.filter(({ type }: GlobalSearchResult) => type === 'application');
aggregatedResults = [...results, ...aggregatedResults].sort(sortByTitle);
aggregatedResults = [...results, ...aggregatedResults].sort(sort.byTitle);
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');
trackUiMetric(METRIC_TYPE.COUNT, COUNT_METRIC.UNHANDLED_ERROR);
},
complete: () => {},
});
@ -199,7 +177,7 @@ export const SearchBar: FC<SearchBarProps> = ({
(event: KeyboardEvent) => {
if (event.key === '/' && (isMac ? event.metaKey : event.ctrlKey)) {
event.preventDefault();
trackUiMetric(METRIC_TYPE.COUNT, 'shortcut_used');
trackUiMetric(METRIC_TYPE.COUNT, COUNT_METRIC.SHORTCUT_USED);
if (searchRef) {
searchRef.focus();
} else if (buttonRef) {
@ -230,16 +208,17 @@ export const SearchBar: FC<SearchBarProps> = ({
// errors in tracking should not prevent selection behavior
try {
if (type === 'application') {
const key = selected.keys ?? 'unknown';
trackUiMetric(METRIC_TYPE.CLICK, [
'user_navigated_to_application',
`user_navigated_to_application_${key.toLowerCase().replaceAll(' ', '_')}`, // which application
]);
const key = selected.key ?? 'unknown';
const application = `${key.toLowerCase().replaceAll(' ', '_')}`;
trackUiMetric(
METRIC_TYPE.CLICK,
getClickMetric(CLICK_METRIC.USER_NAVIGATED_TO_APPLICATION, application)
);
} else {
trackUiMetric(METRIC_TYPE.CLICK, [
'user_navigated_to_saved_object',
`user_navigated_to_saved_object_${type}`, // which type of saved object
]);
trackUiMetric(
METRIC_TYPE.CLICK,
getClickMetric(CLICK_METRIC.USER_NAVIGATED_TO_SAVED_OBJECT, type)
);
}
} catch (e) {
// eslint-disable-next-line no-console
@ -259,34 +238,8 @@ export const SearchBar: FC<SearchBarProps> = ({
const clearField = () => setSearchValue('');
const noMatchesMessage = <PopoverPlaceholder darkMode={darkMode} basePath={basePathUrl} />;
const emptyMessage = (
<EuiFlexGroup direction="column" justifyContent="center" style={{ minHeight: '300px' }}>
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="xl" />
</EuiFlexItem>
</EuiFlexGroup>
);
const placeholderText = i18n.translate('xpack.globalSearchBar.searchBar.placeholder', {
defaultMessage: 'Find apps, content, and more.',
});
const keyboardShortcutTooltip = `${i18n.translate(
'xpack.globalSearchBar.searchBar.shortcutTooltip.description',
{
defaultMessage: 'Keyboard shortcut',
}
)}: ${
isMac
? i18n.translate('xpack.globalSearchBar.searchBar.shortcutTooltip.macCommandDescription', {
defaultMessage: 'Command + /',
})
: i18n.translate(
'xpack.globalSearchBar.searchBar.shortcutTooltip.windowsCommandDescription',
{
defaultMessage: 'Control + /',
}
)
const keyboardShortcutTooltip = `${i18nStrings.keyboardShortcutTooltip.prefix}: ${
isMac ? i18nStrings.keyboardShortcutTooltip.onMac : i18nStrings.keyboardShortcutTooltip.onNotMac
}`;
useEvent('keydown', onKeyDown);
@ -306,10 +259,10 @@ export const SearchBar: FC<SearchBarProps> = ({
'data-test-subj': 'nav-search-input',
inputRef: setSearchRef,
compressed: true,
'aria-label': placeholderText,
placeholder: placeholderText,
'aria-label': i18nStrings.placeholderText,
placeholder: i18nStrings.placeholderText,
onFocus: () => {
trackUiMetric(METRIC_TYPE.COUNT, 'search_focus');
trackUiMetric(METRIC_TYPE.COUNT, COUNT_METRIC.SEARCH_FOCUS);
setInitialLoad(true);
setShowAppend(false);
},
@ -326,8 +279,8 @@ export const SearchBar: FC<SearchBarProps> = ({
</EuiFormLabel>
) : undefined,
}}
emptyMessage={emptyMessage}
noMatchesMessage={noMatchesMessage}
emptyMessage={<EmptyMessage />}
noMatchesMessage={<NoMatchesMessage {...props} />}
popoverProps={{
'data-test-subj': 'nav-search-popover',
panelClassName: 'navSearch__panel',
@ -336,12 +289,7 @@ export const SearchBar: FC<SearchBarProps> = ({
panelStyle: { marginTop: '6px' },
}}
popoverButton={
<EuiHeaderSectionItemButton
aria-label={i18n.translate(
'xpack.globalSearchBar.searchBar.mobileSearchButtonAriaLabel',
{ defaultMessage: 'Site-wide search' }
)}
>
<EuiHeaderSectionItemButton aria-label={i18nStrings.popoverButton}>
<EuiIcon type="search" size="m" />
</EuiHeaderSectionItemButton>
}

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { ApplicationStart } from '@kbn/core/public';
import type { GlobalSearchPluginStart } from '@kbn/global-search-plugin/public';
import type { SavedObjectTaggingPluginStart } from '@kbn/saved-objects-tagging-plugin/public';
import { TrackUiMetricFn } from '../types';
/* @internal */
export interface SearchBarProps {
globalSearch: GlobalSearchPluginStart;
navigateToUrl: ApplicationStart['navigateToUrl'];
trackUiMetric: TrackUiMetricFn;
taggingApi?: SavedObjectTaggingPluginStart;
basePathUrl: string;
darkMode: boolean;
}

View file

@ -8,7 +8,6 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { Observable } from 'rxjs';
import { UiCounterMetricType } from '@kbn/analytics';
import { I18nProvider } from '@kbn/i18n-react';
import { ApplicationStart, CoreTheme, CoreStart, Plugin } from '@kbn/core/public';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
@ -16,6 +15,7 @@ import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public';
import { GlobalSearchPluginStart } from '@kbn/global-search-plugin/public';
import { SavedObjectTaggingPluginStart } from '@kbn/saved-objects-tagging-plugin/public';
import { SearchBar } from './components/search_bar';
import { TrackUiMetricFn } from './types';
export interface GlobalSearchBarPluginStartDeps {
globalSearch: GlobalSearchPluginStart;
@ -32,9 +32,12 @@ export class GlobalSearchBarPlugin implements Plugin<{}, {}> {
core: CoreStart,
{ globalSearch, savedObjectsTagging, usageCollection }: GlobalSearchBarPluginStartDeps
) {
const trackUiMetric = usageCollection
? usageCollection.reportUiCounter.bind(usageCollection, 'global_search_bar')
: (metricType: UiCounterMetricType, eventName: string | string[]) => {};
let trackUiMetric: TrackUiMetricFn = () => {};
if (usageCollection) {
trackUiMetric = (...args) => {
usageCollection.reportUiCounter('global_search_bar', ...args);
};
}
core.chrome.navControls.registerCenter({
order: 1000,
@ -70,7 +73,7 @@ export class GlobalSearchBarPlugin implements Plugin<{}, {}> {
basePathUrl: string;
darkMode: boolean;
theme$: Observable<CoreTheme>;
trackUiMetric: (metricType: UiCounterMetricType, eventName: string | string[]) => void;
trackUiMetric: TrackUiMetricFn;
}) {
ReactDOM.render(
<KibanaThemeProvider theme$={theme$}>

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const i18nStrings = {
placeholderText: i18n.translate('xpack.globalSearchBar.searchBar.placeholder', {
defaultMessage: 'Find apps, content, and more.',
}),
popoverButton: i18n.translate('xpack.globalSearchBar.searchBar.mobileSearchButtonAriaLabel', {
defaultMessage: 'Site-wide search',
}),
keyboardShortcutTooltip: {
prefix: i18n.translate('xpack.globalSearchBar.searchBar.shortcutTooltip.description', {
defaultMessage: 'Keyboard shortcut',
}),
onMac: i18n.translate('xpack.globalSearchBar.searchBar.shortcutTooltip.macCommandDescription', {
defaultMessage: 'Command + /',
}),
onNotMac: i18n.translate(
'xpack.globalSearchBar.searchBar.shortcutTooltip.windowsCommandDescription',
{
defaultMessage: 'Control + /',
}
),
},
};

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { UiCounterMetricType } from '@kbn/analytics';
export type TrackUiMetricFn = (
metricType: UiCounterMetricType,
eventName: string | string[]
) => void;