diff --git a/src/plugins/dashboard/public/application/dashboard_app.tsx b/src/plugins/dashboard/public/application/dashboard_app.tsx index e12b08314419..d060327563b2 100644 --- a/src/plugins/dashboard/public/application/dashboard_app.tsx +++ b/src/plugins/dashboard/public/application/dashboard_app.tsx @@ -192,7 +192,6 @@ export function DashboardApp({ subscriptions.add( merge( - data.search.session.onRefresh$, data.query.timefilter.timefilter.getAutoRefreshFetch$(), searchSessionIdQuery$ ).subscribe(() => { diff --git a/src/plugins/data/public/search/session/mocks.ts b/src/plugins/data/public/search/session/mocks.ts index 4028a3b6c32a..f6a70d157b5a 100644 --- a/src/plugins/data/public/search/session/mocks.ts +++ b/src/plugins/data/public/search/session/mocks.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { BehaviorSubject, Subject } from 'rxjs'; +import { BehaviorSubject } from 'rxjs'; import { ISessionsClient } from './sessions_client'; import { ISessionService } from './session_service'; import { SearchSessionState } from './search_session_state'; @@ -32,8 +32,6 @@ export function getSessionServiceMock(): jest.Mocked { state$: new BehaviorSubject(SearchSessionState.None).asObservable(), trackSearch: jest.fn((searchDescriptor) => () => {}), destroy: jest.fn(), - onRefresh$: new Subject(), - refresh: jest.fn(), cancel: jest.fn(), isStored: jest.fn(), isRestore: jest.fn(), diff --git a/src/plugins/data/public/search/session/session_service.ts b/src/plugins/data/public/search/session/session_service.ts index 475e689da650..79ae64c5846a 100644 --- a/src/plugins/data/public/search/session/session_service.ts +++ b/src/plugins/data/public/search/session/session_service.ts @@ -8,7 +8,7 @@ import { PublicContract } from '@kbn/utility-types'; import { distinctUntilChanged, map, startWith } from 'rxjs/operators'; -import { Observable, Subject, Subscription } from 'rxjs'; +import { Observable, Subscription } from 'rxjs'; import { PluginInitializerContext, StartServicesAccessor } from 'kibana/public'; import { UrlGeneratorId, UrlGeneratorStateMapping } from '../../../../share/public/'; import { ConfigSchema } from '../../../config'; @@ -193,21 +193,6 @@ export class SessionService { this.searchSessionIndicatorUiConfig = undefined; } - private refresh$ = new Subject(); - /** - * Observable emits when search result refresh was requested - * For example, the UI could have it's own "refresh" button - * Application would use this observable to handle user interaction on that button - */ - public onRefresh$ = this.refresh$.asObservable(); - - /** - * Request a search results refresh - */ - public refresh() { - this.refresh$.next(); - } - /** * Request a cancellation of on-going search requests within current session */ diff --git a/src/plugins/discover/public/application/angular/discover.js b/src/plugins/discover/public/application/angular/discover.js index fac5bb2d8de4..13ff8b14d9b4 100644 --- a/src/plugins/discover/public/application/angular/discover.js +++ b/src/plugins/discover/public/application/angular/discover.js @@ -504,13 +504,6 @@ function discoverController($route, $scope, Promise) { ) ); - subscriptions.add( - data.search.session.onRefresh$.subscribe(() => { - searchSessionManager.removeSearchSessionIdFromURL({ replace: false }); - refetch$.next(); - }) - ); - $scope.changeInterval = (interval) => { if (interval) { setAppState({ interval }); diff --git a/test/functional/services/common/browser.ts b/test/functional/services/common/browser.ts index c8cfbf2f2b57..d9212e48f73f 100644 --- a/test/functional/services/common/browser.ts +++ b/test/functional/services/common/browser.ts @@ -461,6 +461,16 @@ export async function BrowserProvider({ getService }: FtrProviderContext) { ); } + /** + * Removes a value in local storage for the focused window/frame. + * + * @param {string} key + * @return {Promise} + */ + public async removeLocalStorageItem(key: string): Promise { + await driver.executeScript('return window.localStorage.removeItem(arguments[0]);', key); + } + /** * Clears session storage for the focused window/frame. * diff --git a/x-pack/plugins/data_enhanced/public/plugin.ts b/x-pack/plugins/data_enhanced/public/plugin.ts index 7a4d12d0ac63..b7d7b7c0e20d 100644 --- a/x-pack/plugins/data_enhanced/public/plugin.ts +++ b/x-pack/plugins/data_enhanced/public/plugin.ts @@ -19,6 +19,7 @@ import { registerSearchSessionsMgmt } from './search/sessions_mgmt'; import { toMountPoint } from '../../../../src/plugins/kibana_react/public'; import { createConnectedSearchSessionIndicator } from './search'; import { ConfigSchema } from '../config'; +import { Storage } from '../../../../src/plugins/kibana_utils/public'; export interface DataEnhancedSetupDependencies { bfetch: BfetchPublicSetup; @@ -37,6 +38,7 @@ export class DataEnhancedPlugin implements Plugin { private enhancedSearchInterceptor!: EnhancedSearchInterceptor; private config!: ConfigSchema; + private readonly storage = new Storage(window.localStorage); constructor(private initializerContext: PluginInitializerContext) {} @@ -83,6 +85,7 @@ export class DataEnhancedPlugin sessionService: plugins.data.search.session, application: core.application, timeFilter: plugins.data.query.timefilter.timefilter, + storage: this.storage, }) ) ), diff --git a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx index ba2b0e0f1503..79e49050941b 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx @@ -6,7 +6,9 @@ */ import React from 'react'; +import { StubBrowserStorage } from '@kbn/test/jest'; import { render, waitFor, screen, act } from '@testing-library/react'; +import { Storage } from '../../../../../../../src/plugins/kibana_utils/public/'; import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; import { createConnectedSearchSessionIndicator } from './connected_search_session_indicator'; import { BehaviorSubject } from 'rxjs'; @@ -17,17 +19,19 @@ import { TimefilterContract, } from '../../../../../../../src/plugins/data/public'; import { coreMock } from '../../../../../../../src/core/public/mocks'; +import { TOUR_RESTORE_STEP_KEY, TOUR_TAKING_TOO_LONG_STEP_KEY } from './search_session_tour'; const coreStart = coreMock.createStart(); const dataStart = dataPluginMock.createStartContract(); const sessionService = dataStart.search.session as jest.Mocked; - +let storage: Storage; const refreshInterval$ = new BehaviorSubject({ value: 0, pause: true }); const timeFilter = dataStart.query.timefilter.timefilter as jest.Mocked; timeFilter.getRefreshIntervalUpdate$.mockImplementation(() => refreshInterval$); timeFilter.getRefreshInterval.mockImplementation(() => refreshInterval$.getValue()); beforeEach(() => { + storage = new Storage(new StubBrowserStorage()); refreshInterval$.next({ value: 0, pause: true }); sessionService.isSessionStorageReady.mockImplementation(() => true); sessionService.getSearchSessionIndicatorUiConfig.mockImplementation(() => ({ @@ -42,6 +46,7 @@ test("shouldn't show indicator in case no active search session", async () => { sessionService, application: coreStart.application, timeFilter, + storage, }); const { getByTestId, container } = render(); @@ -49,7 +54,13 @@ test("shouldn't show indicator in case no active search session", async () => { await expect( waitFor(() => getByTestId('searchSessionIndicator'), { timeout: 100 }) ).rejects.toThrow(); - expect(container).toMatchInlineSnapshot(`
`); + expect(container).toMatchInlineSnapshot(` +
+ + `); }); test("shouldn't show indicator in case app hasn't opt-in", async () => { @@ -57,6 +68,7 @@ test("shouldn't show indicator in case app hasn't opt-in", async () => { sessionService, application: coreStart.application, timeFilter, + storage, }); const { getByTestId, container } = render(); sessionService.isSessionStorageReady.mockImplementation(() => false); @@ -65,7 +77,13 @@ test("shouldn't show indicator in case app hasn't opt-in", async () => { await expect( waitFor(() => getByTestId('searchSessionIndicator'), { timeout: 100 }) ).rejects.toThrow(); - expect(container).toMatchInlineSnapshot(`
`); + expect(container).toMatchInlineSnapshot(` +
+ + `); }); test('should show indicator in case there is an active search session', async () => { @@ -74,6 +92,7 @@ test('should show indicator in case there is an active search session', async () sessionService: { ...sessionService, state$ }, application: coreStart.application, timeFilter, + storage, }); const { getByTestId } = render(); @@ -98,6 +117,7 @@ test('should be disabled in case uiConfig says so ', async () => { sessionService: { ...sessionService, state$ }, application: coreStart.application, timeFilter, + storage, }); render(); @@ -114,6 +134,7 @@ test('should be disabled during auto-refresh', async () => { sessionService: { ...sessionService, state$ }, application: coreStart.application, timeFilter, + storage, }); render(); @@ -128,3 +149,107 @@ test('should be disabled during auto-refresh', async () => { expect(screen.getByTestId('searchSessionIndicator').querySelector('button')).toBeDisabled(); }); + +describe('tour steps', () => { + describe('loading state', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + test('shows tour step on slow loading with delay', async () => { + const state$ = new BehaviorSubject(SearchSessionState.Loading); + const SearchSessionIndicator = createConnectedSearchSessionIndicator({ + sessionService: { ...sessionService, state$ }, + application: coreStart.application, + timeFilter, + storage, + }); + const rendered = render(); + + await waitFor(() => rendered.getByTestId('searchSessionIndicator')); + + expect(() => screen.getByTestId('searchSessionIndicatorPopoverContainer')).toThrow(); + + act(() => { + jest.advanceTimersByTime(10001); + }); + + expect(screen.getByTestId('searchSessionIndicatorPopoverContainer')).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(5000); + state$.next(SearchSessionState.Completed); + }); + + // Open tour should stay on screen after state change + expect(screen.getByTestId('searchSessionIndicatorPopoverContainer')).toBeInTheDocument(); + + expect(storage.get(TOUR_RESTORE_STEP_KEY)).toBeFalsy(); + expect(storage.get(TOUR_TAKING_TOO_LONG_STEP_KEY)).toBeTruthy(); + }); + + test("doesn't show tour step if state changed before delay", async () => { + const state$ = new BehaviorSubject(SearchSessionState.Loading); + const SearchSessionIndicator = createConnectedSearchSessionIndicator({ + sessionService: { ...sessionService, state$ }, + application: coreStart.application, + timeFilter, + storage, + }); + const rendered = render(); + + const searchSessionIndicator = await rendered.findByTestId('searchSessionIndicator'); + expect(searchSessionIndicator).toBeTruthy(); + + act(() => { + jest.advanceTimersByTime(3000); + state$.next(SearchSessionState.Completed); + jest.advanceTimersByTime(3000); + }); + + expect(rendered.queryByTestId('searchSessionIndicatorPopoverContainer')).toBeFalsy(); + + expect(storage.get(TOUR_RESTORE_STEP_KEY)).toBeFalsy(); + expect(storage.get(TOUR_TAKING_TOO_LONG_STEP_KEY)).toBeFalsy(); + }); + }); + + test('shows tour step for restored', async () => { + const state$ = new BehaviorSubject(SearchSessionState.Restored); + const SearchSessionIndicator = createConnectedSearchSessionIndicator({ + sessionService: { ...sessionService, state$ }, + application: coreStart.application, + timeFilter, + storage, + }); + const rendered = render(); + + await waitFor(() => rendered.getByTestId('searchSessionIndicator')); + expect(screen.getByTestId('searchSessionIndicatorPopoverContainer')).toBeInTheDocument(); + + expect(storage.get(TOUR_RESTORE_STEP_KEY)).toBeTruthy(); + expect(storage.get(TOUR_TAKING_TOO_LONG_STEP_KEY)).toBeTruthy(); + }); + + test("doesn't show tour for irrelevant state", async () => { + const state$ = new BehaviorSubject(SearchSessionState.Completed); + const SearchSessionIndicator = createConnectedSearchSessionIndicator({ + sessionService: { ...sessionService, state$ }, + application: coreStart.application, + timeFilter, + storage, + }); + const rendered = render(); + + await waitFor(() => rendered.getByTestId('searchSessionIndicator')); + + expect(rendered.queryByTestId('searchSessionIndicatorPopoverContainer')).toBeFalsy(); + + expect(storage.get(TOUR_RESTORE_STEP_KEY)).toBeFalsy(); + expect(storage.get(TOUR_TAKING_TOO_LONG_STEP_KEY)).toBeFalsy(); + }); +}); diff --git a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx index 985d6ccabeb4..b572db7ebfd4 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx @@ -5,33 +5,47 @@ * 2.0. */ -import React from 'react'; -import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators'; +import React, { useRef } from 'react'; +import { debounce, distinctUntilChanged, map } from 'rxjs/operators'; +import { timer } from 'rxjs'; import useObservable from 'react-use/lib/useObservable'; import { i18n } from '@kbn/i18n'; -import { SearchSessionIndicator } from '../search_session_indicator'; -import { ISessionService, TimefilterContract } from '../../../../../../../src/plugins/data/public/'; +import { SearchSessionIndicator, SearchSessionIndicatorRef } from '../search_session_indicator'; +import { + ISessionService, + SearchSessionState, + TimefilterContract, +} from '../../../../../../../src/plugins/data/public/'; import { RedirectAppLinks } from '../../../../../../../src/plugins/kibana_react/public'; import { ApplicationStart } from '../../../../../../../src/core/public'; +import { IStorageWrapper } from '../../../../../../../src/plugins/kibana_utils/public'; +import { useSearchSessionTour } from './search_session_tour'; export interface SearchSessionIndicatorDeps { sessionService: ISessionService; timeFilter: TimefilterContract; application: ApplicationStart; + storage: IStorageWrapper; } export const createConnectedSearchSessionIndicator = ({ sessionService, application, timeFilter, + storage, }: SearchSessionIndicatorDeps): React.FC => { const isAutoRefreshEnabled = () => !timeFilter.getRefreshInterval().pause; const isAutoRefreshEnabled$ = timeFilter .getRefreshIntervalUpdate$() .pipe(map(isAutoRefreshEnabled), distinctUntilChanged()); + const debouncedSessionServiceState$ = sessionService.state$.pipe( + debounce((_state) => timer(_state === SearchSessionState.None ? 50 : 300)) // switch to None faster to quickly remove indicator when navigating away + ); + return () => { - const state = useObservable(sessionService.state$.pipe(debounceTime(500))); + const ref = useRef(null); + const state = useObservable(debouncedSessionServiceState$, SearchSessionState.None); const autoRefreshEnabled = useObservable(isAutoRefreshEnabled$, isAutoRefreshEnabled()); const isDisabledByApp = sessionService.getSearchSessionIndicatorUiConfig().isDisabled(); @@ -43,21 +57,28 @@ export const createConnectedSearchSessionIndicator = ({ disabledReasonText = i18n.translate( 'xpack.data.searchSessionIndicator.disabledDueToAutoRefreshMessage', { - defaultMessage: 'Send to background is not available when auto refresh is enabled.', + defaultMessage: 'Search sessions are not available when auto refresh is enabled.', } ); } + const { markOpenedDone, markRestoredDone } = useSearchSessionTour( + storage, + ref, + state, + disabled + ); + if (isDisabledByApp.disabled) { disabled = true; disabledReasonText = isDisabledByApp.reasonText; } if (!sessionService.isSessionStorageReady()) return null; - if (!state) return null; return ( { sessionService.save(); @@ -65,14 +86,17 @@ export const createConnectedSearchSessionIndicator = ({ onSaveResults={() => { sessionService.save(); }} - onRefresh={() => { - sessionService.refresh(); - }} onCancel={() => { sessionService.cancel(); }} disabled={disabled} disabledReasonText={disabledReasonText} + onOpened={(openedState) => { + markOpenedDone(); + if (openedState === SearchSessionState.Restored) { + markRestoredDone(); + } + }} /> ); diff --git a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/search_session_tour.tsx b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/search_session_tour.tsx new file mode 100644 index 000000000000..8c04410f9953 --- /dev/null +++ b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/search_session_tour.tsx @@ -0,0 +1,93 @@ +/* + * 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 { MutableRefObject, useCallback, useEffect } from 'react'; +import { IStorageWrapper } from '../../../../../../../src/plugins/kibana_utils/public'; +import { SearchSessionIndicatorRef } from '../search_session_indicator'; +import { SearchSessionState } from '../../../../../../../src/plugins/data/public'; + +const TOUR_TAKING_TOO_LONG_TIMEOUT = 10000; +export const TOUR_TAKING_TOO_LONG_STEP_KEY = `data.searchSession.tour.takingTooLong`; +export const TOUR_RESTORE_STEP_KEY = `data.searchSession.tour.restore`; + +export function useSearchSessionTour( + storage: IStorageWrapper, + searchSessionIndicatorRef: MutableRefObject, + state: SearchSessionState, + searchSessionsDisabled: boolean +) { + const markOpenedDone = useCallback(() => { + safeSet(storage, TOUR_TAKING_TOO_LONG_STEP_KEY); + }, [storage]); + + const markRestoredDone = useCallback(() => { + safeSet(storage, TOUR_RESTORE_STEP_KEY); + }, [storage]); + + useEffect(() => { + if (searchSessionsDisabled) return; + let timeoutHandle: number; + + if (state === SearchSessionState.Loading) { + if (!safeHas(storage, TOUR_TAKING_TOO_LONG_STEP_KEY)) { + timeoutHandle = window.setTimeout(() => { + safeOpen(searchSessionIndicatorRef); + }, TOUR_TAKING_TOO_LONG_TIMEOUT); + } + } + + if (state === SearchSessionState.Restored) { + if (!safeHas(storage, TOUR_RESTORE_STEP_KEY)) { + safeOpen(searchSessionIndicatorRef); + } + } + + return () => { + clearTimeout(timeoutHandle); + }; + }, [ + storage, + searchSessionIndicatorRef, + state, + searchSessionsDisabled, + markOpenedDone, + markRestoredDone, + ]); + + return { + markOpenedDone, + markRestoredDone, + }; +} + +function safeHas(storage: IStorageWrapper, key: string): boolean { + try { + return Boolean(storage.get(key)); + } catch (e) { + return true; + } +} + +function safeSet(storage: IStorageWrapper, key: string) { + try { + storage.set(key, true); + } catch (e) { + return true; + } +} + +function safeOpen(searchSessionIndicatorRef: MutableRefObject) { + if (searchSessionIndicatorRef.current) { + searchSessionIndicatorRef.current.openPopover(); + } else { + // TODO: needed for initial open when component is not rendered yet + // fix after: https://github.com/elastic/eui/issues/4460 + setTimeout(() => { + searchSessionIndicatorRef.current?.openPopover(); + }, 50); + } +} diff --git a/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/custom_icons.tsx b/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/custom_icons.tsx new file mode 100644 index 000000000000..94aa1d41abd3 --- /dev/null +++ b/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/custom_icons.tsx @@ -0,0 +1,49 @@ +/* + * 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 from 'react'; +import { EuiIconProps } from '@elastic/eui'; + +/** + * These are the new icons we've added for search session indicator, + * likely in future we will remove these when they land into EUI + */ +export const CheckInEmptyCircle = ({ title, titleId, ...props }: Omit) => ( + + {title ? {title} : null} + + +); + +export const PartialClock = ({ title, titleId, ...props }: Omit) => ( + + {title ? {title} : null} + + +); diff --git a/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/index.tsx b/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/index.tsx index fd18fb733552..fe86ad2fb5ce 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/index.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/index.tsx @@ -7,8 +7,11 @@ import { EuiDelayRender, EuiLoadingSpinner } from '@elastic/eui'; import React from 'react'; -import type { SearchSessionIndicatorProps } from './search_session_indicator'; -export type { SearchSessionIndicatorProps }; +import type { + SearchSessionIndicatorProps, + SearchSessionIndicatorRef, +} from './search_session_indicator'; +export type { SearchSessionIndicatorProps, SearchSessionIndicatorRef }; const Fallback = () => ( @@ -17,8 +20,11 @@ const Fallback = () => ( ); const LazySearchSessionIndicator = React.lazy(() => import('./search_session_indicator')); -export const SearchSessionIndicator = (props: SearchSessionIndicatorProps) => ( +export const SearchSessionIndicator = React.forwardRef< + SearchSessionIndicatorRef, + SearchSessionIndicatorProps +>((props: SearchSessionIndicatorProps, ref) => ( }> - + -); +)); diff --git a/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.scss b/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.scss index 6f3ae5b5846f..11c7ba7816c3 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.scss +++ b/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.scss @@ -2,22 +2,6 @@ padding: 0 $euiSizeXS; } -@include euiBreakpoint('xs', 's') { - .searchSessionIndicator__popoverContainer.euiFlexGroup--responsive .euiFlexItem { - margin-bottom: $euiSizeXS !important; - } -} - -.searchSessionIndicator__verticalDivider { - @include euiBreakpoint('xs', 's') { - margin-left: $euiSizeXS; - padding-left: $euiSizeXS; - } - - @include euiBreakpoint('m', 'l', 'xl') { - border-left: $euiBorderThin; - align-self: stretch; - margin-left: $euiSizeS; - padding-left: $euiSizeS; - } +.searchSessionIndicator__panel { + width: $euiSize * 18; } diff --git a/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.stories.tsx b/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.stories.tsx index 30dc493f2a31..f2d5a3c52dae 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.stories.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.stories.tsx @@ -27,6 +27,9 @@ storiesOf('components/SearchSessionIndicator', module).add('default', () => (
+
+ +
{ ); - await userEvent.click(screen.getByLabelText('Loading')); - await userEvent.click(screen.getByText('Cancel session')); + await userEvent.click(screen.getByLabelText('Search session loading')); + await userEvent.click(screen.getByText('Stop session')); expect(onCancel).toBeCalled(); }); @@ -38,7 +38,7 @@ test('Completed state', async () => { ); - await userEvent.click(screen.getByLabelText('Loaded')); + await userEvent.click(screen.getByLabelText('Search session complete')); await userEvent.click(screen.getByText('Save session')); expect(onSave).toBeCalled(); @@ -52,8 +52,8 @@ test('Loading in the background state', async () => { ); - await userEvent.click(screen.getByLabelText('Loading results in the background')); - await userEvent.click(screen.getByText('Cancel session')); + await userEvent.click(screen.getByLabelText(/Saved session in progress/)); + await userEvent.click(screen.getByText('Stop session')); expect(onCancel).toBeCalled(); }); @@ -68,38 +68,43 @@ test('BackgroundCompleted state', async () => { ); - await userEvent.click(screen.getByLabelText('Results loaded in the background')); - expect(screen.getByRole('link', { name: 'View all sessions' }).getAttribute('href')).toBe( + await userEvent.click(screen.getByLabelText(/Saved session complete/)); + expect(screen.getByRole('link', { name: 'Manage sessions' }).getAttribute('href')).toBe( '__link__' ); }); test('Restored state', async () => { - const onRefresh = jest.fn(); render( - + ); - await userEvent.click(screen.getByLabelText('Results no longer current')); - await userEvent.click(screen.getByText('Refresh')); + await userEvent.click(screen.getByLabelText(/Saved session restored/)); - expect(onRefresh).toBeCalled(); + expect(screen.getByRole('link', { name: 'Manage sessions' }).getAttribute('href')).toBe( + '__link__' + ); }); test('Canceled state', async () => { - const onRefresh = jest.fn(); render( - + ); - await userEvent.click(screen.getByLabelText('Canceled')); - await userEvent.click(screen.getByText('Refresh')); - - expect(onRefresh).toBeCalled(); + await userEvent.click(screen.getByLabelText(/Search session stopped/)); + expect(screen.getByRole('link', { name: 'Manage sessions' }).getAttribute('href')).toBe( + '__link__' + ); }); test('Disabled state', async () => { diff --git a/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.tsx b/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.tsx index f6387e832fb7..9ac537829a67 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useCallback, useImperativeHandle } from 'react'; import { EuiButtonEmpty, EuiButtonEmptyProps, @@ -15,12 +15,13 @@ import { EuiFlexItem, EuiLoadingSpinner, EuiPopover, + EuiSpacer, EuiText, EuiToolTip, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; - +import { PartialClock, CheckInEmptyCircle } from './custom_icons'; import './search_session_indicator.scss'; import { SearchSessionState } from '../../../../../../../src/plugins/data/public'; @@ -30,9 +31,9 @@ export interface SearchSessionIndicatorProps { onCancel?: () => void; viewSearchSessionsLink?: string; onSaveResults?: () => void; - onRefresh?: () => void; disabled?: boolean; disabledReasonText?: string; + onOpened?: (openedState: SearchSessionState) => void; } type ActionButtonProps = SearchSessionIndicatorProps & { buttonProps: EuiButtonEmptyProps }; @@ -41,11 +42,12 @@ const CancelButton = ({ onCancel = () => {}, buttonProps = {} }: ActionButtonPro ); @@ -61,7 +63,7 @@ const ContinueInBackgroundButton = ({ > ); @@ -72,25 +74,12 @@ const ViewAllSearchSessionsButton = ({ }: ActionButtonProps) => ( - -); - -const RefreshButton = ({ onRefresh = () => {}, buttonProps = {} }: ActionButtonProps) => ( - - ); @@ -114,7 +103,8 @@ const searchSessionIndicatorViewStateToProps: { tooltipText: string; }; popover: { - text: string; + title: string; + description: string; primaryAction?: React.ComponentType; secondaryAction?: React.ComponentType; }; @@ -124,19 +114,22 @@ const searchSessionIndicatorViewStateToProps: { [SearchSessionState.Loading]: { button: { color: 'subdued', - iconType: 'clock', + iconType: PartialClock, 'aria-label': i18n.translate( 'xpack.data.searchSessionIndicator.loadingResultsIconAriaLabel', - { defaultMessage: 'Loading' } + { defaultMessage: 'Search session loading' } ), tooltipText: i18n.translate( 'xpack.data.searchSessionIndicator.loadingResultsIconTooltipText', - { defaultMessage: 'Loading' } + { defaultMessage: 'Search session loading' } ), }, popover: { - text: i18n.translate('xpack.data.searchSessionIndicator.loadingResultsText', { - defaultMessage: 'Loading', + title: i18n.translate('xpack.data.searchSessionIndicator.loadingResultsTitle', { + defaultMessage: 'Your search is taking a while...', + }), + description: i18n.translate('xpack.data.searchSessionIndicator.loadingResultsDescription', { + defaultMessage: 'Save your session, continue your work, and return to completed results.', }), primaryAction: CancelButton, secondaryAction: ContinueInBackgroundButton, @@ -145,21 +138,27 @@ const searchSessionIndicatorViewStateToProps: { [SearchSessionState.Completed]: { button: { color: 'subdued', - iconType: 'checkInCircleFilled', + iconType: 'clock', 'aria-label': i18n.translate('xpack.data.searchSessionIndicator.resultsLoadedIconAriaLabel', { - defaultMessage: 'Loaded', + defaultMessage: 'Search session complete', }), tooltipText: i18n.translate( 'xpack.data.searchSessionIndicator.resultsLoadedIconTooltipText', { - defaultMessage: 'Results loaded', + defaultMessage: 'Search session complete', } ), }, popover: { - text: i18n.translate('xpack.data.searchSessionIndicator.resultsLoadedText', { - defaultMessage: 'Loaded', + title: i18n.translate('xpack.data.searchSessionIndicator.resultsLoadedText', { + defaultMessage: 'Search session complete', }), + description: i18n.translate( + 'xpack.data.searchSessionIndicator.resultsLoadedDescriptionText', + { + defaultMessage: 'Save your session and return to it later.', + } + ), primaryAction: SaveButton, secondaryAction: ViewAllSearchSessionsButton, }, @@ -170,20 +169,26 @@ const searchSessionIndicatorViewStateToProps: { 'aria-label': i18n.translate( 'xpack.data.searchSessionIndicator.loadingInTheBackgroundIconAriaLabel', { - defaultMessage: 'Loading results in the background', + defaultMessage: 'Saved session in progress', } ), tooltipText: i18n.translate( 'xpack.data.searchSessionIndicator.loadingInTheBackgroundIconTooltipText', { - defaultMessage: 'Loading results in the background', + defaultMessage: 'Saved session in progress', } ), }, popover: { - text: i18n.translate('xpack.data.searchSessionIndicator.loadingInTheBackgroundText', { - defaultMessage: 'Loading in the background', + title: i18n.translate('xpack.data.searchSessionIndicator.loadingInTheBackgroundTitleText', { + defaultMessage: 'Saved session in progress', }), + description: i18n.translate( + 'xpack.data.searchSessionIndicator.loadingInTheBackgroundDescriptionText', + { + defaultMessage: 'You can return to completed results from Management.', + } + ), primaryAction: CancelButton, secondaryAction: ViewAllSearchSessionsButton, }, @@ -193,74 +198,118 @@ const searchSessionIndicatorViewStateToProps: { color: 'success', iconType: 'checkInCircleFilled', 'aria-label': i18n.translate( - 'xpack.data.searchSessionIndicator.resultLoadedInTheBackgroundIconAraText', + 'xpack.data.searchSessionIndicator.resultLoadedInTheBackgroundIconAriaLabel', { - defaultMessage: 'Results loaded in the background', + defaultMessage: 'Saved session complete', } ), tooltipText: i18n.translate( 'xpack.data.searchSessionIndicator.resultLoadedInTheBackgroundIconTooltipText', { - defaultMessage: 'Results loaded in the background', + defaultMessage: 'Saved session complete', } ), }, popover: { - text: i18n.translate('xpack.data.searchSessionIndicator.resultLoadedInTheBackgroundText', { - defaultMessage: 'Loaded', - }), - primaryAction: ViewAllSearchSessionsButton, + title: i18n.translate( + 'xpack.data.searchSessionIndicator.resultLoadedInTheBackgroundTitleText', + { + defaultMessage: 'Search session saved', + } + ), + description: i18n.translate( + 'xpack.data.searchSessionIndicator.resultLoadedInTheBackgroundDescriptionText', + { + defaultMessage: 'You can return to these results from Management.', + } + ), + secondaryAction: ViewAllSearchSessionsButton, }, }, [SearchSessionState.Restored]: { button: { - color: 'warning', - iconType: 'refresh', + color: 'success', + iconType: CheckInEmptyCircle, 'aria-label': i18n.translate( 'xpack.data.searchSessionIndicator.restoredResultsIconAriaLabel', { - defaultMessage: 'Results no longer current', + defaultMessage: 'Saved session restored', } ), tooltipText: i18n.translate('xpack.data.searchSessionIndicator.restoredResultsTooltipText', { - defaultMessage: 'Results no longer current', + defaultMessage: 'Search session restored', }), }, popover: { - text: i18n.translate('xpack.data.searchSessionIndicator.restoredText', { - defaultMessage: 'Results no longer current', + title: i18n.translate('xpack.data.searchSessionIndicator.restoredTitleText', { + defaultMessage: 'Search session restored', + }), + description: i18n.translate('xpack.data.searchSessionIndicator.restoredDescriptionText', { + defaultMessage: + 'You are viewing cached data from a specific time range. Changing the time range or filters will re-run the session.', }), - primaryAction: RefreshButton, secondaryAction: ViewAllSearchSessionsButton, }, }, [SearchSessionState.Canceled]: { button: { - color: 'subdued', - iconType: 'refresh', + color: 'danger', + iconType: 'alert', 'aria-label': i18n.translate('xpack.data.searchSessionIndicator.canceledIconAriaLabel', { - defaultMessage: 'Canceled', + defaultMessage: 'Search session stopped', }), tooltipText: i18n.translate('xpack.data.searchSessionIndicator.canceledTooltipText', { - defaultMessage: 'Search was canceled', + defaultMessage: 'Search session stopped', }), }, popover: { - text: i18n.translate('xpack.data.searchSessionIndicator.canceledText', { - defaultMessage: 'Search was canceled', + title: i18n.translate('xpack.data.searchSessionIndicator.canceledTitleText', { + defaultMessage: 'Search session stopped', + }), + description: i18n.translate('xpack.data.searchSessionIndicator.canceledDescriptionText', { + defaultMessage: 'You are viewing incomplete data.', }), - primaryAction: RefreshButton, secondaryAction: ViewAllSearchSessionsButton, }, }, }; -const VerticalDivider: React.FC = () =>
; +export interface SearchSessionIndicatorRef { + openPopover: () => void; + closePopover: () => void; +} -export const SearchSessionIndicator: React.FC = (props) => { +export const SearchSessionIndicator = React.forwardRef< + SearchSessionIndicatorRef, + SearchSessionIndicatorProps +>((props, ref) => { const [isPopoverOpen, setIsPopoverOpen] = React.useState(false); - const onButtonClick = () => setIsPopoverOpen((isOpen) => !isOpen); - const closePopover = () => setIsPopoverOpen(false); + const closePopover = useCallback(() => setIsPopoverOpen(false), []); + const onOpened = props.onOpened; + const openPopover = useCallback(() => { + setIsPopoverOpen(true); + if (onOpened) onOpened(props.state); + }, [onOpened, props.state]); + const onButtonClick = useCallback(() => { + if (isPopoverOpen) { + closePopover(); + } else { + openPopover(); + } + }, [isPopoverOpen, openPopover, closePopover]); + + useImperativeHandle( + ref, + () => ({ + openPopover: () => { + openPopover(); + }, + closePopover: () => { + closePopover(); + }, + }), + [openPopover, closePopover] + ); if (!searchSessionIndicatorViewStateToProps[props.state]) return null; @@ -271,13 +320,18 @@ export const SearchSessionIndicator: React.FC = (pr ownFocus isOpen={isPopoverOpen} closePopover={closePopover} - anchorPosition={'rightCenter'} - panelPaddingSize={'s'} + anchorPosition={'downLeft'} + panelPaddingSize={'m'} className="searchSessionIndicator" data-test-subj={'searchSessionIndicator'} data-state={props.state} + panelClassName={'searchSessionIndicator__panel'} + repositionOnScroll={true} button={ - + = (pr } > - - - -

{popover.text}

-
-
- - - {popover.primaryAction && ( - - - - )} - {popover.primaryAction && popover.secondaryAction && } - {popover.secondaryAction && ( - - - - )} - - -
+
+ +

{popover.title}

+
+ + +

{popover.description}

+
+ + + {popover.primaryAction && ( + + + + )} + {popover.secondaryAction && ( + + + + )} + +
); -}; +}); // React.lazy() needs default: // eslint-disable-next-line import/no-default-export diff --git a/x-pack/test/send_search_to_background_integration/services/search_sessions.ts b/x-pack/test/send_search_to_background_integration/services/search_sessions.ts index 2756ce2c4f82..69b3e0594634 100644 --- a/x-pack/test/send_search_to_background_integration/services/search_sessions.ts +++ b/x-pack/test/send_search_to_background_integration/services/search_sessions.ts @@ -13,6 +13,9 @@ import { FtrProviderContext } from '../ftr_provider_context'; const SEARCH_SESSION_INDICATOR_TEST_SUBJ = 'searchSessionIndicator'; const SEARCH_SESSIONS_POPOVER_CONTENT_TEST_SUBJ = 'searchSessionIndicatorPopoverContainer'; +export const TOUR_TAKING_TOO_LONG_STEP_KEY = `data.searchSession.tour.takingTooLong`; +export const TOUR_RESTORE_STEP_KEY = `data.searchSession.tour.restore`; + type SessionStateType = | 'none' | 'loading' @@ -61,7 +64,7 @@ export function SearchSessionsProvider({ getService }: FtrProviderContext) { public async viewSearchSessions() { log.debug('viewSearchSessions'); await this.ensurePopoverOpened(); - await testSubjects.click('searchSessionIndicatorviewSearchSessionsLink'); + await testSubjects.click('searchSessionIndicatorViewSearchSessionsLink'); } public async save() { @@ -78,15 +81,20 @@ export function SearchSessionsProvider({ getService }: FtrProviderContext) { await this.ensurePopoverClosed(); } - public async refresh() { - log.debug('refresh the status'); + public async openPopover() { await this.ensurePopoverOpened(); - await testSubjects.click('searchSessionIndicatorRefreshBtn'); - await this.ensurePopoverClosed(); } - public async openPopover() { - await this.ensurePopoverOpened(); + public async openedOrFail() { + return testSubjects.existOrFail(SEARCH_SESSIONS_POPOVER_CONTENT_TEST_SUBJ, { + timeout: 15000, // because popover auto opens after search takes 10s + }); + } + + public async closedOrFail() { + return testSubjects.missingOrFail(SEARCH_SESSIONS_POPOVER_CONTENT_TEST_SUBJ, { + timeout: 15000, // because popover auto opens after search takes 10s + }); } private async ensurePopoverOpened() { @@ -143,5 +151,19 @@ export function SearchSessionsProvider({ getService }: FtrProviderContext) { ); }); } + + public async markTourDone() { + await Promise.all([ + browser.setLocalStorageItem(TOUR_TAKING_TOO_LONG_STEP_KEY, 'true'), + browser.setLocalStorageItem(TOUR_RESTORE_STEP_KEY, 'true'), + ]); + } + + public async markTourUndone() { + await Promise.all([ + browser.removeLocalStorageItem(TOUR_TAKING_TOO_LONG_STEP_KEY), + browser.removeLocalStorageItem(TOUR_RESTORE_STEP_KEY), + ]); + } })(); } diff --git a/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/index.ts b/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/index.ts index 101657f796c9..5a912117fe44 100644 --- a/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/index.ts +++ b/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/index.ts @@ -7,9 +7,11 @@ import { FtrProviderContext } from '../../../../ftr_provider_context'; -export default function ({ loadTestFile, getService }: FtrProviderContext) { +export default function ({ loadTestFile, getService, getPageObjects }: FtrProviderContext) { const kibanaServer = getService('kibanaServer'); const esArchiver = getService('esArchiver'); + const PageObjects = getPageObjects(['common']); + const searchSessions = getService('searchSessions'); describe('async search', function () { this.tags('ciGroup3'); @@ -19,6 +21,11 @@ export default function ({ loadTestFile, getService }: FtrProviderContext) { await esArchiver.load('dashboard/async_search'); await kibanaServer.uiSettings.replace({ defaultIndex: 'logstash-*' }); await kibanaServer.uiSettings.replace({ 'search:timeout': 10000 }); + await PageObjects.common.navigateToApp('dashboard'); + }); + + beforeEach(async () => { + await searchSessions.markTourDone(); }); after(async () => { @@ -28,6 +35,7 @@ export default function ({ loadTestFile, getService }: FtrProviderContext) { loadTestFile(require.resolve('./async_search')); loadTestFile(require.resolve('./send_to_background')); loadTestFile(require.resolve('./send_to_background_relative_time')); + loadTestFile(require.resolve('./search_sessions_tour')); loadTestFile(require.resolve('./sessions_in_space')); }); } diff --git a/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/search_sessions_tour.ts b/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/search_sessions_tour.ts new file mode 100644 index 000000000000..e12bd377288b --- /dev/null +++ b/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/search_sessions_tour.ts @@ -0,0 +1,62 @@ +/* + * 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 { FtrProviderContext } from '../../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const es = getService('es'); + const log = getService('log'); + const PageObjects = getPageObjects(['common', 'header', 'dashboard', 'visChart']); + const browser = getService('browser'); + const searchSessions = getService('searchSessions'); + const kibanaServer = getService('kibanaServer'); + + describe('search sessions tour', () => { + before(async function () { + const { body } = await es.info(); + if (!body.version.number.includes('SNAPSHOT')) { + log.debug('Skipping because this build does not have the required shard_delay agg'); + this.skip(); + return; + } + await kibanaServer.uiSettings.replace({ 'search:timeout': 30000 }); + }); + + beforeEach(async () => { + await PageObjects.common.navigateToApp('dashboard'); + await searchSessions.markTourUndone(); + }); + + after(async function () { + await searchSessions.deleteAllSearchSessions(); + await kibanaServer.uiSettings.replace({ 'search:timeout': 10000 }); + await searchSessions.markTourDone(); + }); + + it('search session popover auto opens when search is taking a while', async () => { + await PageObjects.dashboard.loadSavedDashboard('Delayed 15s'); + + await searchSessions.openedOrFail(); // tour auto opens when there is a long running search + + await PageObjects.header.waitUntilLoadingHasFinished(); + await searchSessions.expectState('completed'); + + const url = await browser.getCurrentUrl(); + const fakeSessionId = '__fake__'; + const savedSessionURL = `${url}&searchSessionId=${fakeSessionId}`; + await browser.get(savedSessionURL); + await PageObjects.header.waitUntilLoadingHasFinished(); + await searchSessions.expectState('restored'); + await searchSessions.openedOrFail(); // tour auto opens on first restore + + await browser.get(savedSessionURL); + await PageObjects.header.waitUntilLoadingHasFinished(); + await searchSessions.expectState('restored'); + await searchSessions.closedOrFail(); // do not open on next restore + }); + }); +} diff --git a/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/send_to_background.ts b/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/send_to_background.ts index a6169951e21b..dc7e5b60f5e1 100644 --- a/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/send_to_background.ts +++ b/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/send_to_background.ts @@ -16,6 +16,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const dashboardPanelActions = getService('dashboardPanelActions'); const browser = getService('browser'); const searchSessions = getService('searchSessions'); + const queryBar = getService('queryBar'); describe('send to background', () => { before(async function () { @@ -46,7 +47,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); expect(session1).to.be(fakeSessionId); - await searchSessions.refresh(); + await queryBar.clickQuerySubmitButton(); await PageObjects.header.waitUntilLoadingHasFinished(); await searchSessions.expectState('completed'); await testSubjects.missingOrFail('embeddableErrorLabel'); @@ -65,6 +66,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(url).to.contain('searchSessionId'); await PageObjects.header.waitUntilLoadingHasFinished(); await searchSessions.expectState('restored'); + expect( await dashboardPanelActions.getSearchSessionIdByTitle('Sum of Bytes by Extension') ).to.be(fakeSessionId); diff --git a/x-pack/test/send_search_to_background_integration/tests/apps/discover/index.ts b/x-pack/test/send_search_to_background_integration/tests/apps/discover/index.ts index 69db8b83f45b..42f7560b82f4 100644 --- a/x-pack/test/send_search_to_background_integration/tests/apps/discover/index.ts +++ b/x-pack/test/send_search_to_background_integration/tests/apps/discover/index.ts @@ -7,9 +7,11 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; -export default function ({ loadTestFile, getService }: FtrProviderContext) { +export default function ({ loadTestFile, getService, getPageObjects }: FtrProviderContext) { const kibanaServer = getService('kibanaServer'); const esArchiver = getService('esArchiver'); + const PageObjects = getPageObjects(['common']); + const searchSessions = getService('searchSessions'); describe('async search', function () { this.tags('ciGroup3'); @@ -17,6 +19,11 @@ export default function ({ loadTestFile, getService }: FtrProviderContext) { before(async () => { await esArchiver.loadIfNeeded('logstash_functional'); await kibanaServer.uiSettings.replace({ defaultIndex: 'logstash-*' }); + await PageObjects.common.navigateToApp('discover'); + }); + + beforeEach(async () => { + await searchSessions.markTourDone(); }); loadTestFile(require.resolve('./async_search')); diff --git a/x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/sessions_management.ts b/x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/sessions_management.ts index 94ad6c21419d..7e09c6b0fe05 100644 --- a/x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/sessions_management.ts +++ b/x-pack/test/send_search_to_background_integration/tests/apps/management/search_sessions/sessions_management.ts @@ -31,6 +31,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { retry.tryForTime(10000, async () => { testSubjects.existOrFail('dashboardLandingPage'); }); + await searchSessions.markTourDone(); }); after(async () => {