diff --git a/src/plugins/data_explorer/public/components/query_bar.tsx b/src/plugins/data_explorer/public/components/query_bar.tsx new file mode 100644 index 000000000000..ba4dd9a24672 --- /dev/null +++ b/src/plugins/data_explorer/public/components/query_bar.tsx @@ -0,0 +1,685 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import React, { Component, RefObject, createRef } from 'react'; +import { i18n } from '@osd/i18n'; + +import classNames from 'classnames'; +import { + EuiTextArea, + EuiOutsideClickDetector, + PopoverAnchorPosition, + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiLink, + htmlIdGenerator, + EuiPortal, +} from '@elastic/eui'; + +import { FormattedMessage } from '@osd/i18n/react'; +import { debounce, compact, isEqual, isFunction } from 'lodash'; +import { Toast } from 'src/core/public'; +import { IIndexPattern, Query } from 'src/plugins/data/common'; +import { PersistedLog, fromUser, getQueryLog, matchPairs, toUser } from 'src/plugins/data/public/query'; +import SuggestionsComponent, { SuggestionsListSize } from 'src/plugins/data/public/ui/typeahead/suggestions_component'; +import { IDataPluginServices, QuerySuggestion, QuerySuggestionTypes } from 'src/plugins/data/public'; +import { OpenSearchDashboardsReactContextValue, toMountPoint } from 'src/plugins/opensearch_dashboards_react/public'; +import { fetchIndexPatterns } from 'src/plugins/data/public/ui/query_string_input/fetch_index_patterns'; +import { QueryLanguageSwitcher } from 'src/plugins/data/public/ui/query_string_input/language_switcher'; + + +export interface QueryStringInputProps { + indexPatterns: Array; + query: Query; + disableAutoFocus?: boolean; + screenTitle?: string; + prepend?: any; + persistedLog?: PersistedLog; + bubbleSubmitEvent?: boolean; + placeholder?: string; + languageSwitcherPopoverAnchorPosition?: PopoverAnchorPosition; + onBlur?: () => void; + onChange?: (query: Query) => void; + onChangeQueryInputFocus?: (isFocused: boolean) => void; + onSubmit?: (query: Query) => void; + dataTestSubj?: string; + size?: SuggestionsListSize; + className?: string; + isInvalid?: boolean; +} + +interface Props extends QueryStringInputProps { + opensearchDashboards: OpenSearchDashboardsReactContextValue; +} + +interface State { + isSuggestionsVisible: boolean; + index: number | null; + suggestions: QuerySuggestion[]; + suggestionLimit: number; + selectionStart: number | null; + selectionEnd: number | null; + indexPatterns: IIndexPattern[]; + queryBarRect: DOMRect | undefined; +} + +const KEY_CODES = { + LEFT: 37, + UP: 38, + RIGHT: 39, + DOWN: 40, + ENTER: 13, + ESC: 27, + TAB: 9, + HOME: 36, + END: 35, +}; + +// Needed for React.lazy +// eslint-disable-next-line import/no-default-export +export default class DataExplorerQueryStringInputUI extends Component { + public state: State = { + isSuggestionsVisible: false, + index: null, + suggestions: [], + suggestionLimit: 50, + selectionStart: null, + selectionEnd: null, + indexPatterns: [], + queryBarRect: undefined, + }; + + public inputRef: HTMLTextAreaElement | null = null; + + private persistedLog: PersistedLog | undefined; + private abortController?: AbortController; + private services = this.props.opensearchDashboards.services; + private componentIsUnmounting = false; + private queryBarInputDivRefInstance: RefObject = createRef(); + + private getQueryString = () => { + return toUser(this.props.query.query); + }; + + private fetchIndexPatterns = async () => { + const stringPatterns = this.props.indexPatterns.filter( + (indexPattern) => typeof indexPattern === 'string' + ) as string[]; + const objectPatterns = this.props.indexPatterns.filter( + (indexPattern) => typeof indexPattern !== 'string' + ) as IIndexPattern[]; + + const objectPatternsFromStrings = (await fetchIndexPatterns( + this.services.savedObjects!.client, + stringPatterns, + this.services.uiSettings! + )) as IIndexPattern[]; + + this.setState({ + indexPatterns: [...objectPatterns, ...objectPatternsFromStrings], + }); + }; + + private getSuggestions = async () => { + if (!this.inputRef) { + return; + } + + const language = this.props.query.language; + const queryString = this.getQueryString(); + + const recentSearchSuggestions = this.getRecentSearchSuggestions(queryString); + const hasQuerySuggestions = this.services.data.autocomplete.hasQuerySuggestions(language); + + if ( + !hasQuerySuggestions || + !Array.isArray(this.state.indexPatterns) || + compact(this.state.indexPatterns).length === 0 + ) { + return recentSearchSuggestions; + } + + const indexPatterns = this.state.indexPatterns; + + const { selectionStart, selectionEnd } = this.inputRef; + if (selectionStart === null || selectionEnd === null) { + return; + } + + try { + if (this.abortController) this.abortController.abort(); + this.abortController = new AbortController(); + const suggestions = + (await this.services.data.autocomplete.getQuerySuggestions({ + language, + indexPatterns, + query: queryString, + selectionStart, + selectionEnd, + signal: this.abortController.signal, + })) || []; + + return [...suggestions, ...recentSearchSuggestions]; + } catch (e) { + // TODO: Waiting on https://github.com/elastic/kibana/issues/51406 for a properly typed error + // Ignore aborted requests + if (e.message === 'The user aborted a request.') return; + throw e; + } + }; + + private getRecentSearchSuggestions = (query: string) => { + if (!this.persistedLog) { + return []; + } + const recentSearches = this.persistedLog.get(); + const matchingRecentSearches = recentSearches.filter((recentQuery) => { + const recentQueryString = typeof recentQuery === 'object' ? toUser(recentQuery) : recentQuery; + return recentQueryString.includes(query); + }); + return matchingRecentSearches.map((recentSearch) => { + const text = toUser(recentSearch); + const start = 0; + const end = query.length; + return { type: QuerySuggestionTypes.RecentSearch, text, start, end }; + }); + }; + + private updateSuggestions = debounce(async () => { + const suggestions = (await this.getSuggestions()) || []; + if (!this.componentIsUnmounting) { + this.setState({ suggestions }); + } + }, 100); + + private onSubmit = (query: Query) => { + if (this.props.onSubmit) { + if (this.persistedLog) { + this.persistedLog.add(query.query); + } + + this.props.onSubmit({ query: fromUser(query.query), language: query.language }); + } + }; + + private onChange = (query: Query) => { + this.updateSuggestions(); + + if (this.props.onChange) { + this.props.onChange({ query: fromUser(query.query), language: query.language }); + } + }; + + private onQueryStringChange = (value: string) => { + this.setState({ + isSuggestionsVisible: true, + index: null, + suggestionLimit: 50, + }); + + this.onChange({ query: value, language: this.props.query.language }); + }; + + private onInputChange = (event: React.ChangeEvent) => { + this.onQueryStringChange(event.target.value); + if (event.target.value === '') { + this.handleRemoveHeight(); + } else { + this.handleAutoHeight(); + } + }; + + private onClickInput = (event: React.MouseEvent) => { + if (event.target instanceof HTMLTextAreaElement) { + this.onQueryStringChange(event.target.value); + } + }; + + private onKeyUp = (event: React.KeyboardEvent) => { + if ([KEY_CODES.LEFT, KEY_CODES.RIGHT, KEY_CODES.HOME, KEY_CODES.END].includes(event.keyCode)) { + this.setState({ isSuggestionsVisible: true }); + if (event.target instanceof HTMLTextAreaElement) { + this.onQueryStringChange(event.target.value); + } + } + }; + + private onKeyDown = (event: React.KeyboardEvent) => { + if (event.target instanceof HTMLTextAreaElement) { + const { isSuggestionsVisible, index } = this.state; + const preventDefault = event.preventDefault.bind(event); + const { target, key, metaKey } = event; + const { value, selectionStart, selectionEnd } = target; + const updateQuery = (query: string, newSelectionStart: number, newSelectionEnd: number) => { + this.onQueryStringChange(query); + this.setState({ + selectionStart: newSelectionStart, + selectionEnd: newSelectionEnd, + }); + }; + + switch (event.keyCode) { + case KEY_CODES.DOWN: + if (isSuggestionsVisible && index !== null) { + event.preventDefault(); + this.incrementIndex(index); + // Note to engineers. `isSuggestionVisible` does not mean the suggestions are visible. + // This should likely be fixed, it's more that suggestions can be shown. + } else if ((isSuggestionsVisible && index == null) || this.getQueryString() === '') { + event.preventDefault(); + this.setState({ isSuggestionsVisible: true, index: 0 }); + } + break; + case KEY_CODES.UP: + if (isSuggestionsVisible && index !== null) { + event.preventDefault(); + this.decrementIndex(index); + } + break; + case KEY_CODES.ENTER: + if (!this.props.bubbleSubmitEvent) { + event.preventDefault(); + } + if (isSuggestionsVisible && index !== null && this.state.suggestions[index]) { + event.preventDefault(); + this.selectSuggestion(this.state.suggestions[index]); + } else { + this.onSubmit(this.props.query); + this.setState({ + isSuggestionsVisible: false, + }); + } + break; + case KEY_CODES.ESC: + event.preventDefault(); + this.setState({ isSuggestionsVisible: false, index: null }); + break; + case KEY_CODES.TAB: + this.setState({ isSuggestionsVisible: false, index: null }); + break; + default: + if (selectionStart !== null && selectionEnd !== null) { + matchPairs({ + value, + selectionStart, + selectionEnd, + key, + metaKey, + updateQuery, + preventDefault, + }); + } + + break; + } + } + }; + + private selectSuggestion = (suggestion: QuerySuggestion) => { + if (!this.inputRef) { + return; + } + const { type, text, start, end, cursorIndex } = suggestion; + + this.handleNestedFieldSyntaxNotification(suggestion); + + const query = this.getQueryString(); + const { selectionStart, selectionEnd } = this.inputRef; + if (selectionStart === null || selectionEnd === null) { + return; + } + + const value = query.substr(0, selectionStart) + query.substr(selectionEnd); + const newQueryString = value.substr(0, start) + text + value.substr(end); + + this.onQueryStringChange(newQueryString); + + this.setState({ + selectionStart: start + (cursorIndex ? cursorIndex : text.length), + selectionEnd: start + (cursorIndex ? cursorIndex : text.length), + }); + + if (type === QuerySuggestionTypes.RecentSearch) { + this.setState({ isSuggestionsVisible: false, index: null }); + this.onSubmit({ query: newQueryString, language: this.props.query.language }); + } + }; + + private handleNestedFieldSyntaxNotification = (suggestion: QuerySuggestion) => { + if ( + 'field' in suggestion && + suggestion.field.subType && + suggestion.field.subType.nested && + !this.services.storage.get('opensearchDashboards.DQLNestedQuerySyntaxInfoOptOut') + ) { + const { notifications, docLinks } = this.services; + + const onDQLNestedQuerySyntaxInfoOptOut = (toast: Toast) => { + if (!this.services.storage) return; + this.services.storage.set('opensearchDashboards.DQLNestedQuerySyntaxInfoOptOut', true); + notifications!.toasts.remove(toast); + }; + + if (notifications && docLinks) { + const toast = notifications.toasts.add({ + title: i18n.translate('data.query.queryBar.DQLNestedQuerySyntaxInfoTitle', { + defaultMessage: 'DQL nested query syntax', + }), + text: toMountPoint( +
+

+ + + + ), + }} + /> +

+ + + onDQLNestedQuerySyntaxInfoOptOut(toast)}> + + + + +
+ ), + }); + } + } + }; + + private increaseLimit = () => { + this.setState({ + suggestionLimit: this.state.suggestionLimit + 50, + }); + }; + + private incrementIndex = (currentIndex: number) => { + let nextIndex = currentIndex + 1; + if (currentIndex === null || nextIndex >= this.state.suggestions.length) { + nextIndex = 0; + } + this.setState({ index: nextIndex }); + }; + + private decrementIndex = (currentIndex: number) => { + const previousIndex = currentIndex - 1; + if (previousIndex < 0) { + this.setState({ index: this.state.suggestions.length - 1 }); + } else { + this.setState({ index: previousIndex }); + } + }; + + private onSelectLanguage = (language: string) => { + // Send telemetry info every time the user opts in or out of kuery + // As a result it is important this function only ever gets called in the + // UI component's change handler. + this.services.http.post('/api/opensearch-dashboards/dql_opt_in_stats', { + body: JSON.stringify({ opt_in: language === 'kuery' }), + }); + + this.services.storage.set('opensearchDashboards.userQueryLanguage', language); + + const newQuery = { query: '', language }; + this.onChange(newQuery); + this.onSubmit(newQuery); + }; + + private onOutsideClick = () => { + if (this.state.isSuggestionsVisible) { + this.setState({ isSuggestionsVisible: false, index: null }); + } + this.handleBlurHeight(); + if (this.props.onChangeQueryInputFocus) { + this.props.onChangeQueryInputFocus(false); + } + }; + + private onInputBlur = () => { + this.handleBlurHeight(); + if (this.props.onChangeQueryInputFocus) { + this.props.onChangeQueryInputFocus(false); + } + if (isFunction(this.props.onBlur)) { + this.props.onBlur(); + } + }; + + private onClickSuggestion = (suggestion: QuerySuggestion) => { + if (!this.inputRef) { + return; + } + this.selectSuggestion(suggestion); + this.inputRef.focus(); + }; + + private initPersistedLog = () => { + const { uiSettings, storage, appName } = this.services; + this.persistedLog = this.props.persistedLog + ? this.props.persistedLog + : getQueryLog(uiSettings, storage, appName, this.props.query.language); + }; + + public onMouseEnterSuggestion = (index: number) => { + this.setState({ index }); + }; + + textareaId = htmlIdGenerator()(); + + public componentDidMount() { + const parsedQuery = fromUser(toUser(this.props.query.query)); + if (!isEqual(this.props.query.query, parsedQuery)) { + this.onChange({ ...this.props.query, query: parsedQuery }); + } + + this.initPersistedLog(); + this.fetchIndexPatterns().then(this.updateSuggestions); + this.handleListUpdate(); + + window.addEventListener('resize', this.handleAutoHeight); + window.addEventListener('scroll', this.handleListUpdate, { + passive: true, // for better performance as we won't call preventDefault + capture: true, // scroll events don't bubble, they must be captured instead + }); + } + + public componentDidUpdate(prevProps: Props) { + const parsedQuery = fromUser(toUser(this.props.query.query)); + if (!isEqual(this.props.query.query, parsedQuery)) { + this.onChange({ ...this.props.query, query: parsedQuery }); + } + + this.initPersistedLog(); + + if (!isEqual(prevProps.indexPatterns, this.props.indexPatterns)) { + this.fetchIndexPatterns().then(this.updateSuggestions); + } else if (!isEqual(prevProps.query, this.props.query)) { + this.updateSuggestions(); + } + + if (this.state.selectionStart !== null && this.state.selectionEnd !== null) { + if (this.inputRef != null) { + this.inputRef.setSelectionRange(this.state.selectionStart, this.state.selectionEnd); + } + this.setState({ + selectionStart: null, + selectionEnd: null, + }); + if (document.activeElement !== null && document.activeElement.id === this.textareaId) { + this.handleAutoHeight(); + } else { + this.handleRemoveHeight(); + } + } + } + + public componentWillUnmount() { + if (this.abortController) this.abortController.abort(); + if (this.updateSuggestions.cancel) this.updateSuggestions.cancel(); + this.componentIsUnmounting = true; + window.removeEventListener('resize', this.handleAutoHeight); + window.removeEventListener('scroll', this.handleListUpdate, { capture: true }); + } + + handleListUpdate = () => { + if (this.componentIsUnmounting) return; + + return this.setState({ + queryBarRect: this.queryBarInputDivRefInstance.current?.getBoundingClientRect(), + }); + }; + + handleAutoHeight = () => { + if (this.inputRef !== null && document.activeElement === this.inputRef) { + this.inputRef.style.setProperty('height', `${this.inputRef.scrollHeight}px`, 'important'); + } + this.handleListUpdate(); + }; + + handleRemoveHeight = () => { + if (this.inputRef !== null) { + this.inputRef.style.removeProperty('height'); + } + }; + + handleBlurHeight = () => { + if (this.inputRef !== null) { + this.handleRemoveHeight(); + this.inputRef.scrollTop = 0; + } + }; + + handleOnFocus = () => { + if (this.props.onChangeQueryInputFocus) { + this.props.onChangeQueryInputFocus(true); + } + requestAnimationFrame(() => { + this.handleAutoHeight(); + }); + }; + + public render() { + const isSuggestionsVisible = this.state.isSuggestionsVisible && { + 'aria-controls': 'osdTypeahead__items', + 'aria-owns': 'osdTypeahead__items', + }; + const ariaCombobox = { ...isSuggestionsVisible, role: 'combobox' }; + const className = classNames( + 'euiFormControlLayout euiFormControlLayout--group osdQueryBar__wrap', + this.props.className + ); + + return ( +
+ {this.props.prepend} + +
+
+ { + if (node) { + this.inputRef = node; + } + }} + autoComplete="off" + spellCheck={false} + aria-label={i18n.translate('data.query.queryBar.searchInputAriaLabel', { + defaultMessage: 'Start typing to search and filter the {pageType} page', + values: { pageType: this.services.appName }, + })} + aria-autocomplete="list" + aria-controls={this.state.isSuggestionsVisible ? 'osdTypeahead__items' : undefined} + aria-activedescendant={ + this.state.isSuggestionsVisible && typeof this.state.index === 'number' + ? `suggestion-${this.state.index}` + : undefined + } + role="textbox" + data-test-subj={this.props.dataTestSubj || 'queryInput'} + isInvalid={this.props.isInvalid} + > + {this.getQueryString()} + +
+ + + +
+
+ + +
+ ); + } +} diff --git a/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx b/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx index feb7b91e7c5e..e3f9ec8af90a 100644 --- a/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx +++ b/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx @@ -4,28 +4,47 @@ */ import React, { useEffect, useMemo, useState } from 'react'; -import { TimeRange, Query } from 'src/plugins/data/common'; -import { AppMountParameters } from '../../../../../../core/public'; +import { TimeRange, Query, doesKueryExpressionHaveLuceneSyntaxError } from 'src/plugins/data/common'; +import { AppMountParameters, Toast } from '../../../../../../core/public'; import { PLUGIN_ID } from '../../../../common'; -import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public'; +import { toMountPoint, useOpenSearchDashboards, withOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public'; import { DiscoverViewServices } from '../../../build_services'; import { IndexPattern } from '../../../opensearch_dashboards_services'; import { getTopNavLinks } from '../../components/top_nav/get_top_nav_links'; import { useDiscoverContext } from '../context'; import { getRootBreadcrumbs } from '../../helpers/breadcrumbs'; import { opensearchFilters, connectStorageToQueryState } from '../../../../../data/public'; +import DataExplorerQueryStringInputUI from 'src/plugins/data_explorer/public/components/query_bar'; +import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui'; +import { PersistedLog, getQueryLog } from 'src/plugins/data/public/query'; +import { FormattedMessage } from 'react-intl'; +import { i18n } from '@osd/i18n'; + +const QueryStringInput = withOpenSearchDashboards(DataExplorerQueryStringInputUI); export interface TopNavProps { opts: { setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; - onQuerySubmit: (payload: { dateRange: TimeRange; query?: Query }, isUpdate?: boolean) => void; + onQuerySubmit: (payload: { dateRange?: TimeRange; query?: Query }, isUpdate?: boolean) => void; }; } export const TopNav = ({ opts }: TopNavProps) => { const { services } = useOpenSearchDashboards(); + const { uiSettings, notifications, docLinks } = services const { inspectorAdapters, savedSearch, indexPattern } = useDiscoverContext(); const [indexPatterns, setIndexPatterns] = useState(undefined); + const [isQueryInputFocused, setIsQueryInputFocused] = useState(false); + const [query, setQuery] = useState() + const queryLanguage = props.query && props.query.language; + const osdDQLDocs: string = docLinks!.links.opensearchDashboards.dql.base; + // const persistedLog: PersistedLog | undefined = React.useMemo( + // () => + // queryLanguage && uiSettings && storage && appName + // ? getQueryLog(uiSettings!, storage, appName, queryLanguage) + // : undefined, + // [appName, queryLanguage, uiSettings, storage] + // ); const { navigation: { @@ -79,17 +98,105 @@ export const TopNav = ({ opts }: TopNavProps) => { indexPattern, ]); + function onChangeQueryInputFocus(isFocused: boolean) { + setIsQueryInputFocused(isFocused); + } + + const onQueryBarChange = (query?: Query ) => { + setQuery(query) + // if (this.props.onQueryChange) { + // this.props.onQueryChange(queryAndDateRange); + // } + }; + + const onLuceneSyntaxWarningOptOut = (toast: Toast) => { + if (!storage) return; + storage.set('opensearchDashboards.luceneSyntaxWarningOptOut', true); + notifications!.toasts.remove(toast); + } + + const handleLuceneSyntaxWarning = (queryCombo?: Query) => { + if (!queryCombo) return; + const { query, language } = queryCombo; + if ( + language === 'kuery' && + typeof query === 'string' && + //(!storage || !storage.get('opensearchDashboards.luceneSyntaxWarningOptOut')) && + doesKueryExpressionHaveLuceneSyntaxError(query) + ) { + const toast = notifications!.toasts.addWarning({ + title: i18n.translate('data.query.queryBar.luceneSyntaxWarningTitle', { + defaultMessage: 'Lucene syntax warning', + }), + text: toMountPoint( +
+

+ + + + ), + }} + /> +

+ + + onLuceneSyntaxWarningOptOut(toast)}> + + + + +
+ ), + }); + } + } + + const onSubmit = (query?: Query) => { + handleLuceneSyntaxWarning(query); + + opts.onQuerySubmit({query}); + } + return ( - + + + + + + /> + + ); }; diff --git a/src/plugins/discover/public/build_services.ts b/src/plugins/discover/public/build_services.ts index 785e72536417..93398c629f1c 100644 --- a/src/plugins/discover/public/build_services.ts +++ b/src/plugins/discover/public/build_services.ts @@ -58,6 +58,8 @@ import { OpenSearchDashboardsLegacyStart } from '../../opensearch_dashboards_leg import { UrlForwardingStart } from '../../url_forwarding/public'; import { NavigationPublicPluginStart } from '../../navigation/public'; import { DataExplorerServices } from '../../data_explorer/public'; +import { IStorageWrapper } from 'src/plugins/opensearch_dashboards_utils/public'; +import { useWindowScroll } from 'react-use'; export interface DiscoverServices { addBasePath: (path: string) => string; @@ -122,7 +124,7 @@ export function buildServices( timefilter: plugins.data.query.timefilter.timefilter, toastNotifications: core.notifications.toasts, uiSettings: core.uiSettings, - visualizations: plugins.visualizations, + visualizations: plugins.visualizations }; } diff --git a/src/plugins/discover/public/plugin.ts b/src/plugins/discover/public/plugin.ts index f8e0f254f925..814952c56467 100644 --- a/src/plugins/discover/public/plugin.ts +++ b/src/plugins/discover/public/plugin.ts @@ -32,7 +32,7 @@ import rison from 'rison-node'; import { lazy } from 'react'; import { DataPublicPluginStart, DataPublicPluginSetup, opensearchFilters } from '../../data/public'; import { SavedObjectLoader } from '../../saved_objects/public'; -import { url } from '../../opensearch_dashboards_utils/public'; +import { IStorageWrapper, url } from '../../opensearch_dashboards_utils/public'; import { DEFAULT_APP_CATEGORIES } from '../../../core/public'; import { UrlGeneratorState } from '../../share/public'; import { DocViewInput, DocViewInputFn } from './application/doc_views/doc_views_types'; @@ -152,7 +152,6 @@ export interface DiscoverStartPlugins { */ export class DiscoverPlugin implements Plugin { - constructor(private readonly initializerContext: PluginInitializerContext) {} private appStateUpdater = new BehaviorSubject(() => ({})); private docViewsRegistry: DocViewsRegistry | null = null; @@ -161,6 +160,11 @@ export class DiscoverPlugin private servicesInitialized: boolean = false; private urlGenerator?: DiscoverStart['urlGenerator']; private initializeServices?: () => { core: CoreStart; plugins: DiscoverStartPlugins }; + private readonly storage: IStorageWrapper; + + constructor(private readonly initializerContext: PluginInitializerContext) { + this.storage = new Storage(window.localStorage); + } setup(core: CoreSetup, plugins: DiscoverSetupPlugins) { const baseUrl = core.http.basePath.prepend('/app/discover');