[7.17] [Global Search] Limit characters for global search bar (#186560) (#188311)

# Backport

This will backport the following commits from `main` to `7.17`:
- [[Global Search] Limit characters for global search bar
(#186560)](https://github.com/elastic/kibana/pull/186560)

<!--- Backport version: 8.9.8 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Rachel
Shen","email":"rshen@elastic.co"},"sourceCommit":{"committedDate":"2024-07-11T14:50:20Z","message":"[Global
Search] Limit characters for global search bar (#186560)\n\n##
Summary\r\n\r\nThis PR limits the number of characters that can be input
into the\r\nglobal search bar. The character limit can be specified with
the config\r\nvalue `xpack.global_search_bar.input_max_limit` with a
default of\r\n`1000`. When an input that exceeds the configured
character limit is\r\nprovided a descriptive visual notice is displayed
to the user.\r\n\r\n## Visual \r\n<img width=\"662\" alt=\"Screenshot
2024-07-04 at 19 28
39\"\r\nsrc=\"cf30f589-fe65-40a9-b9c8-ce0f235d206e\">\r\n\r\n\r\n##
How to test\r\n\r\n- run the following command below in the browser
console, which would\r\ncreate a string that exceeds the configured
default search character\r\nlimit and copy it to your
clipboard\r\n```ts\r\n\tcopy(Array.from(new Array(1001)).reduce((acc) =>
acc+'a', ''))\r\n```\r\n- open up kibana, simply paste the value that
should exist in your\r\nclipboard in the global search input field and
you should be presented\r\nwith a result similar to the image
above.\r\n\r\n---------\r\n\r\nCo-authored-by: Eyo Okon Eyo
<eyo.eyo@elastic.co>","sha":"f57d9c5f01e8e7cdf338251ce031d597d8fd56a4","branchLabelMapping":{"^v8.16.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","auto-backport","backport:prev-MAJOR","backport:all-open"],"number":186560,"url":"https://github.com/elastic/kibana/pull/186560","mergeCommit":{"message":"[Global
Search] Limit characters for global search bar (#186560)\n\n##
Summary\r\n\r\nThis PR limits the number of characters that can be input
into the\r\nglobal search bar. The character limit can be specified with
the config\r\nvalue `xpack.global_search_bar.input_max_limit` with a
default of\r\n`1000`. When an input that exceeds the configured
character limit is\r\nprovided a descriptive visual notice is displayed
to the user.\r\n\r\n## Visual \r\n<img width=\"662\" alt=\"Screenshot
2024-07-04 at 19 28
39\"\r\nsrc=\"cf30f589-fe65-40a9-b9c8-ce0f235d206e\">\r\n\r\n\r\n##
How to test\r\n\r\n- run the following command below in the browser
console, which would\r\ncreate a string that exceeds the configured
default search character\r\nlimit and copy it to your
clipboard\r\n```ts\r\n\tcopy(Array.from(new Array(1001)).reduce((acc) =>
acc+'a', ''))\r\n```\r\n- open up kibana, simply paste the value that
should exist in your\r\nclipboard in the global search input field and
you should be presented\r\nwith a result similar to the image
above.\r\n\r\n---------\r\n\r\nCo-authored-by: Eyo Okon Eyo
<eyo.eyo@elastic.co>","sha":"f57d9c5f01e8e7cdf338251ce031d597d8fd56a4"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[]}]
BACKPORT-->

Co-authored-by: Rachel Shen <rshen@elastic.co>
This commit is contained in:
Eyo O. Eyo 2024-07-18 21:11:58 +02:00 committed by GitHub
parent 4950f7136e
commit 735fb1b0d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 201 additions and 42 deletions

View file

@ -192,6 +192,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) {
'xpack.discoverEnhanced.actions.exploreDataInContextMenu.enabled (boolean)',
'xpack.fleet.agents.enabled (boolean)',
'xpack.global_search.search_timeout (duration)',
'xpack.global_search_bar.input_max_limit (number)',
'xpack.graph.canEditDrillDownUrls (boolean)',
'xpack.graph.savePolicy (alternatives)',
'xpack.ilm.ui.enabled (boolean)',

View file

@ -6,7 +6,7 @@
},
"version": "8.0.0",
"kibanaVersion": "kibana",
"server": false,
"server": true,
"ui": true,
"requiredPlugins": ["globalSearch"],
"optionalPlugins": ["usageCollection", "savedObjectsTagging"],

View file

@ -0,0 +1,66 @@
/*
* 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 React, { FC } from 'react';
import { EuiImage, EuiText, EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
interface PopoverPlaceholderProps {
basePath: string;
customPlaceholderMessage?: React.ReactNode;
}
export const PopoverPlaceholder: FC<PopoverPlaceholderProps> = ({
basePath,
customPlaceholderMessage,
}) => {
const { colorMode } = useEuiTheme();
return (
<EuiFlexGroup
style={{ minHeight: 300 }}
data-test-subj="nav-search-no-results"
direction="column"
gutterSize="xs"
alignItems="center"
justifyContent="center"
>
<EuiFlexItem grow={false}>
<EuiImage
alt={i18n.translate('xpack.globalSearchBar.searchBar.noResultsImageAlt', {
defaultMessage: 'Illustration of black hole',
})}
size="fullWidth"
url={`${basePath}illustration_product_no_search_results_${
colorMode === 'DARK' ? 'dark' : 'light'
}.svg`}
/>
{customPlaceholderMessage ?? (
<>
<EuiText size="m">
<p>
<FormattedMessage
id="xpack.globalSearchBar.searchBar.noResultsHeading"
defaultMessage="No results found"
/>
</p>
</EuiText>
<p>
<FormattedMessage
id="xpack.globalSearchBar.searchBar.noResults"
defaultMessage="Try searching for applications, dashboards, visualizations, and more."
/>
</p>
</>
)}
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -42,6 +42,9 @@ const createResult = (result: Result): GlobalSearchResult => {
const createBatch = (...results: Result[]): GlobalSearchBatchedResults => ({
results: results.map(createResult),
});
const searchCharLimit = 1000;
jest.useFakeTimers({ legacyFakeTimers: true });
describe('SearchBar', () => {
@ -96,7 +99,7 @@ describe('SearchBar', () => {
render(
<IntlProvider locale="en">
<SearchBar
globalSearch={searchService}
globalSearch={{ ...searchService, searchCharLimit }}
navigateToUrl={applications.navigateToUrl}
basePathUrl={basePathUrl}
darkMode={darkMode}
@ -124,7 +127,7 @@ describe('SearchBar', () => {
render(
<IntlProvider locale="en">
<SearchBar
globalSearch={searchService}
globalSearch={{ ...searchService, searchCharLimit }}
navigateToUrl={applications.navigateToUrl}
basePathUrl={basePathUrl}
darkMode={darkMode}
@ -156,7 +159,7 @@ describe('SearchBar', () => {
render(
<IntlProvider locale="en">
<SearchBar
globalSearch={searchService}
globalSearch={{ ...searchService, searchCharLimit }}
navigateToUrl={applications.navigateToUrl}
basePathUrl={basePathUrl}
darkMode={darkMode}

View file

@ -11,13 +11,12 @@ import {
EuiFlexItem,
EuiHeaderSectionItemButton,
EuiIcon,
EuiImage,
EuiSelectableMessage,
EuiSelectableTemplateSitewide,
EuiSelectableTemplateSitewideOption,
EuiText,
EuiBadge,
euiSelectableTemplateSitewideRenderOptions,
EuiLoadingSpinner,
} from '@elastic/eui';
import { METRIC_TYPE, UiCounterMetricType } from '@kbn/analytics';
import { i18n } from '@kbn/i18n';
@ -36,10 +35,11 @@ import {
import { SavedObjectTaggingPluginStart, Tag } from '../../../saved_objects_tagging/public';
import { parseSearchParams } from '../search_syntax';
import { getSuggestions, SearchSuggestion } from '../suggestions';
import { PopoverPlaceholder } from './popover_placeholder';
import './search_bar.scss';
interface Props {
globalSearch: GlobalSearchPluginStart;
globalSearch: GlobalSearchPluginStart & { searchCharLimit: number };
navigateToUrl: ApplicationStart['navigateToUrl'];
trackUiMetric: (metricType: UiCounterMetricType, eventName: string | string[]) => void;
taggingApi?: SavedObjectTaggingPluginStart;
@ -88,6 +88,43 @@ const TagListWrapper = ({ children }: { children: ReactNode }) => (
</ul>
);
const NoMatchesMessage = (props: { basePathUrl: string }) => {
return <PopoverPlaceholder basePath={props.basePathUrl} />;
};
const SearchCharLimitExceededMessage = (props: { basePathUrl: string }) => {
const charLimitMessage = (
<>
<EuiText size="m">
<p data-test-subj="searchCharLimitExceededMessageHeading">
<FormattedMessage
id="xpack.globalSearchBar.searchBar.searchCharLimitExceededHeading"
defaultMessage="Search character limit exceeded"
/>
</p>
</EuiText>
<p>
<FormattedMessage
id="xpack.globalSearchBar.searchBar.searchCharLimitExceeded"
defaultMessage="Try searching for applications, dashboards, visualizations, and more."
/>
</p>
</>
);
return (
<PopoverPlaceholder basePath={props.basePathUrl} customPlaceholderMessage={charLimitMessage} />
);
};
const EmptyMessage = () => (
<EuiFlexGroup direction="column" justifyContent="center" style={{ minHeight: '300px' }}>
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="xl" />
</EuiFlexItem>
</EuiFlexGroup>
);
const buildListItem = ({ color, name, id }: Tag) => {
return (
<li className="kbnSearchOption__tagsListItem" key={id}>
@ -189,6 +226,7 @@ export function SearchBar({
const [options, _setOptions] = useState<EuiSelectableTemplateSitewideOption[]>([]);
const [searchableTypes, setSearchableTypes] = useState<string[]>([]);
const UNKNOWN_TAG_ID = '__unknown__';
const [searchCharLimitExceeded, setSearchCharLimitExceeded] = useState(false);
useEffect(() => {
if (initialLoad) {
@ -244,6 +282,14 @@ export function SearchBar({
searchSubscription.current = null;
}
if (searchValue.length > globalSearch.searchCharLimit) {
// setting this will display cause a more descriptive error message to be displayed to the user
setSearchCharLimitExceeded(true);
return setOptions([], []);
} else {
setSearchCharLimitExceeded(false);
}
const suggestions = loadSuggestions(searchValue);
let aggregatedResults: GlobalSearchResult[] = [];
@ -357,34 +403,6 @@ export function SearchBar({
}
};
const emptyMessage = (
<EuiSelectableMessage style={{ minHeight: 300 }} data-test-subj="nav-search-no-results">
<EuiImage
alt={i18n.translate('xpack.globalSearchBar.searchBar.noResultsImageAlt', {
defaultMessage: 'Illustration of black hole',
})}
size="fullWidth"
url={`${basePathUrl}illustration_product_no_search_results_${
darkMode ? 'dark' : 'light'
}.svg`}
/>
<EuiText size="m">
<p>
<FormattedMessage
id="xpack.globalSearchBar.searchBar.noResultsHeading"
defaultMessage="No results found"
/>
</p>
</EuiText>
<p>
<FormattedMessage
id="xpack.globalSearchBar.searchBar.noResults"
defaultMessage="Try searching for applications, dashboards, visualizations, and more."
/>
</p>
</EuiSelectableMessage>
);
useEvent('keydown', onKeyDown);
return (
@ -422,14 +440,20 @@ export function SearchBar({
setInitialLoad(true);
},
}}
emptyMessage={<EmptyMessage />}
noMatchesMessage={
searchCharLimitExceeded ? (
<SearchCharLimitExceededMessage basePathUrl={basePathUrl} />
) : (
<NoMatchesMessage basePathUrl={basePathUrl} />
)
}
popoverProps={{
'data-test-subj': 'nav-search-popover',
panelClassName: 'navSearch__panel',
repositionOnScroll: true,
buttonRef: setButtonRef,
}}
emptyMessage={emptyMessage}
noMatchesMessage={emptyMessage}
popoverFooter={
<EuiFlexGroup
alignItems="center"

View file

@ -8,4 +8,5 @@
import { PluginInitializer } from 'src/core/public';
import { GlobalSearchBarPlugin } from './plugin';
export const plugin: PluginInitializer<{}, {}, {}, {}> = () => new GlobalSearchBarPlugin();
export const plugin: PluginInitializer<{}, {}, {}, {}> = (initializerContext) =>
new GlobalSearchBarPlugin(initializerContext);

View file

@ -10,19 +10,29 @@ import ReactDOM from 'react-dom';
import { UiCounterMetricType } from '@kbn/analytics';
import { I18nProvider } from '@kbn/i18n/react';
import { ApplicationStart } from 'kibana/public';
import { CoreStart, Plugin } from 'src/core/public';
import { CoreStart, Plugin, PluginInitializerContext } from 'src/core/public';
import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/public';
import { GlobalSearchPluginStart } from '../../global_search/public';
import { SavedObjectTaggingPluginStart } from '../../saved_objects_tagging/public';
import { SearchBar } from './components/search_bar';
export interface GlobalSearchBarPluginStartDeps {
globalSearch: GlobalSearchPluginStart;
globalSearch: GlobalSearchPluginStart & { searchCharLimit: number };
savedObjectsTagging?: SavedObjectTaggingPluginStart;
usageCollection?: UsageCollectionSetup;
}
export interface GlobalSearchBarConfigType {
input_max_limit: number;
}
export class GlobalSearchBarPlugin implements Plugin<{}, {}> {
private config: GlobalSearchBarConfigType;
constructor(initializerContext: PluginInitializerContext) {
this.config = initializerContext.config.get<GlobalSearchBarConfigType>();
}
public setup() {
return {};
}
@ -40,7 +50,7 @@ export class GlobalSearchBarPlugin implements Plugin<{}, {}> {
mount: (container) =>
this.mount({
container,
globalSearch,
globalSearch: { ...globalSearch, searchCharLimit: this.config.input_max_limit },
savedObjectsTagging,
navigateToUrl: core.application.navigateToUrl,
basePathUrl: core.http.basePath.prepend('/plugins/globalSearchBar/assets/'),
@ -61,7 +71,7 @@ export class GlobalSearchBarPlugin implements Plugin<{}, {}> {
trackUiMetric,
}: {
container: HTMLElement;
globalSearch: GlobalSearchPluginStart;
globalSearch: GlobalSearchBarPluginStartDeps['globalSearch'];
savedObjectsTagging?: SavedObjectTaggingPluginStart;
navigateToUrl: ApplicationStart['navigateToUrl'];
basePathUrl: string;

View file

@ -0,0 +1,24 @@
/*
* 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 { PluginConfigDescriptor } from 'src/core/server';
import { schema, TypeOf } from '@kbn/config-schema';
export const configSchema = schema.object({
input_max_limit: schema.number({
defaultValue: 1000,
}),
});
export type ConfigSchema = TypeOf<typeof configSchema>;
export const config: PluginConfigDescriptor<ConfigSchema> = {
schema: configSchema,
exposeToBrowser: {
input_max_limit: true,
},
};

View file

@ -0,0 +1,12 @@
/*
* 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 { GlobalSearchBarPlugin } from './plugin';
export const plugin = () => new GlobalSearchBarPlugin();
export { config } from './config';

View file

@ -0,0 +1,18 @@
/*
* 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 { Plugin } from 'src/core/server';
export class GlobalSearchBarPlugin implements Plugin {
setup() {
return {};
}
start() {
return {};
}
}