[Search Session] Revamp search session indicator UI and tour (#89703) (#90277)

This commit is contained in:
Anton Dosov 2021-02-04 17:00:58 +01:00 committed by GitHub
parent 378764bccd
commit 012d453624
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 617 additions and 184 deletions

View file

@ -192,7 +192,6 @@ export function DashboardApp({
subscriptions.add(
merge(
data.search.session.onRefresh$,
data.query.timefilter.timefilter.getAutoRefreshFetch$(),
searchSessionIdQuery$
).subscribe(() => {

View file

@ -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<ISessionService> {
state$: new BehaviorSubject<SearchSessionState>(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(),

View file

@ -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<void>();
/**
* 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
*/

View file

@ -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 });

View file

@ -459,6 +459,16 @@ export async function BrowserProvider({ getService }: FtrProviderContext) {
);
}
/**
* Removes a value in local storage for the focused window/frame.
*
* @param {string} key
* @return {Promise<void>}
*/
public async removeLocalStorageItem(key: string): Promise<void> {
await driver.executeScript('return window.localStorage.removeItem(arguments[0]);', key);
}
/**
* Clears session storage for the focused window/frame.
*

View file

@ -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<void, void, DataEnhancedSetupDependencies, DataEnhancedStartDependencies> {
private enhancedSearchInterceptor!: EnhancedSearchInterceptor;
private config!: ConfigSchema;
private readonly storage = new Storage(window.localStorage);
constructor(private initializerContext: PluginInitializerContext<ConfigSchema>) {}
@ -83,6 +85,7 @@ export class DataEnhancedPlugin
sessionService: plugins.data.search.session,
application: core.application,
timeFilter: plugins.data.query.timefilter.timefilter,
storage: this.storage,
})
)
),

View file

@ -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<ISessionService>;
let storage: Storage;
const refreshInterval$ = new BehaviorSubject<RefreshInterval>({ value: 0, pause: true });
const timeFilter = dataStart.query.timefilter.timefilter as jest.Mocked<TimefilterContract>;
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(<SearchSessionIndicator />);
@ -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(`<div />`);
expect(container).toMatchInlineSnapshot(`
<div>
<div
class="kbnRedirectCrossAppLinks"
/>
</div>
`);
});
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(<SearchSessionIndicator />);
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(`<div />`);
expect(container).toMatchInlineSnapshot(`
<div>
<div
class="kbnRedirectCrossAppLinks"
/>
</div>
`);
});
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(<SearchSessionIndicator />);
@ -98,6 +117,7 @@ test('should be disabled in case uiConfig says so ', async () => {
sessionService: { ...sessionService, state$ },
application: coreStart.application,
timeFilter,
storage,
});
render(<SearchSessionIndicator />);
@ -114,6 +134,7 @@ test('should be disabled during auto-refresh', async () => {
sessionService: { ...sessionService, state$ },
application: coreStart.application,
timeFilter,
storage,
});
render(<SearchSessionIndicator />);
@ -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(<SearchSessionIndicator />);
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(<SearchSessionIndicator />);
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(<SearchSessionIndicator />);
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(<SearchSessionIndicator />);
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();
});
});

View file

@ -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<SearchSessionIndicatorRef>(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 (
<RedirectAppLinks application={application}>
<SearchSessionIndicator
ref={ref}
state={state}
onContinueInBackground={() => {
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();
}
}}
/>
</RedirectAppLinks>
);

View file

@ -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<SearchSessionIndicatorRef | null>,
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<SearchSessionIndicatorRef | null>) {
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);
}
}

View file

@ -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<EuiIconProps, 'type'>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
width={16}
height={16}
aria-labelledby={titleId}
{...props}
>
{title ? <title id={titleId}>{title}</title> : null}
<path
fillRule="evenodd"
clipRule="evenodd"
d="M15 8c0 3.866-3.134 7-7 7-3.86599 0-7-3.134-7-7 0-3.86599 3.13401-7 7-7 3.866 0 7 3.13401 7 7zm1 0c0 4.4183-3.5817 8-8 8-4.41828 0-8-3.5817-8-8 0-4.41828 3.58172-8 8-8 4.4183 0 8 3.58172 8 8zm-9.14533 2.6459c.098.097.226.146.354.146.128 0 .256-.049.354-.146l4.79173-4.79165c.195-.196.195-.512 0-.708-.196-.195-.512-.195-.708 0L7.20867 9.58486 4.85424 7.2295c-.196-.195-.512-.195-.708 0-.195.196-.195.512 0 .708l2.70843 2.7084z"
/>
</svg>
);
export const PartialClock = ({ title, titleId, ...props }: Omit<EuiIconProps, 'type'>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
width={16}
height={16}
aria-labelledby={titleId}
{...props}
>
{title ? <title id={titleId}>{title}</title> : null}
<path
fillRule="evenodd"
clipRule="evenodd"
d="M7.5 13c3.033 0 5.5-2.467 5.5-5.5S10.533 2 7.5 2c-.27614 0-.5-.22386-.5-.5s.22386-.5.5-.5C11.09 1 14 3.91 14 7.5S11.09 14 7.5 14 1 11.09 1 7.5c0-.27614.22386-.5.5-.5s.5.22386.5.5C2 10.533 4.467 13 7.5 13zM4.6724 1.96808c0 .27614.22386.5.5.5s.5-.22386.5-.5-.22386-.5-.5-.5-.5.22386-.5.5zM2.8627 3.15836c0 .27614.22386.5.5.5s.5-.22386.5-.5c0-.27615-.22386-.5-.5-.5s-.5.22385-.5.5zm-.82355 2.33755c-.27615 0-.5-.22386-.5-.5s.22385-.5.5-.5c.27614 0 .5.22386.5.5s-.22386.5-.5.5zM10.5 7H8V3.5c0-.276-.224-.5-.5-.5s-.5.224-.5.5v4c0 .276.224.5.5.5h3c.276 0 .5-.224.5-.5s-.224-.5-.5-.5z"
/>
</svg>
);

View file

@ -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 = () => (
<EuiDelayRender>
@ -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) => (
<React.Suspense fallback={<Fallback />}>
<LazySearchSessionIndicator {...props} />
<LazySearchSessionIndicator {...props} ref={ref} />
</React.Suspense>
);
));

View file

@ -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;
}

View file

@ -27,6 +27,9 @@ storiesOf('components/SearchSessionIndicator', module).add('default', () => (
<div>
<SearchSessionIndicator state={SearchSessionState.Restored} />
</div>
<div>
<SearchSessionIndicator state={SearchSessionState.Canceled} />
</div>
<div>
<SearchSessionIndicator
state={SearchSessionState.Completed}

View file

@ -24,8 +24,8 @@ test('Loading state', async () => {
</Container>
);
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 () => {
</Container>
);
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 () => {
</Container>
);
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 () => {
</Container>
);
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(
<Container>
<SearchSessionIndicator state={SearchSessionState.Restored} onRefresh={onRefresh} />
<SearchSessionIndicator
state={SearchSessionState.Restored}
viewSearchSessionsLink={'__link__'}
/>
</Container>
);
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(
<Container>
<SearchSessionIndicator state={SearchSessionState.Canceled} onRefresh={onRefresh} />
<SearchSessionIndicator
state={SearchSessionState.Canceled}
viewSearchSessionsLink={'__link__'}
/>
</Container>
);
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 () => {

View file

@ -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
<EuiButtonEmpty
onClick={onCancel}
data-test-subj={'searchSessionIndicatorCancelBtn'}
color="danger"
{...buttonProps}
>
<FormattedMessage
id="xpack.data.searchSessionIndicator.cancelButtonText"
defaultMessage="Cancel session"
defaultMessage="Stop session"
/>
</EuiButtonEmpty>
);
@ -61,7 +63,7 @@ const ContinueInBackgroundButton = ({
>
<FormattedMessage
id="xpack.data.searchSessionIndicator.continueInBackgroundButtonText"
defaultMessage="Continue in background"
defaultMessage="Save session"
/>
</EuiButtonEmpty>
);
@ -72,25 +74,12 @@ const ViewAllSearchSessionsButton = ({
}: ActionButtonProps) => (
<EuiButtonEmpty
href={viewSearchSessionsLink}
data-test-subj={'searchSessionIndicatorviewSearchSessionsLink'}
data-test-subj={'searchSessionIndicatorViewSearchSessionsLink'}
{...buttonProps}
>
<FormattedMessage
id="xpack.data.searchSessionIndicator.viewSearchSessionsLinkText"
defaultMessage="View all sessions"
/>
</EuiButtonEmpty>
);
const RefreshButton = ({ onRefresh = () => {}, buttonProps = {} }: ActionButtonProps) => (
<EuiButtonEmpty
onClick={onRefresh}
data-test-subj={'searchSessionIndicatorRefreshBtn'}
{...buttonProps}
>
<FormattedMessage
id="xpack.data.searchSessionIndicator.refreshButtonText"
defaultMessage="Refresh"
defaultMessage="Manage sessions"
/>
</EuiButtonEmpty>
);
@ -114,7 +103,8 @@ const searchSessionIndicatorViewStateToProps: {
tooltipText: string;
};
popover: {
text: string;
title: string;
description: string;
primaryAction?: React.ComponentType<ActionButtonProps>;
secondaryAction?: React.ComponentType<ActionButtonProps>;
};
@ -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 = () => <div className="searchSessionIndicator__verticalDivider" />;
export interface SearchSessionIndicatorRef {
openPopover: () => void;
closePopover: () => void;
}
export const SearchSessionIndicator: React.FC<SearchSessionIndicatorProps> = (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<SearchSessionIndicatorProps> = (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={
<EuiToolTip content={props.disabled ? props.disabledReasonText : button.tooltipText}>
<EuiToolTip
content={props.disabled ? props.disabledReasonText : button.tooltipText}
delay={props.disabled ? 'regular' : 'long'}
>
<EuiButtonIcon
color={button.color}
aria-label={button['aria-label']}
@ -288,37 +342,37 @@ export const SearchSessionIndicator: React.FC<SearchSessionIndicatorProps> = (pr
</EuiToolTip>
}
>
<EuiFlexGroup
responsive={true}
alignItems={'center'}
gutterSize={'s'}
className="searchSessionIndicator__popoverContainer"
data-test-subj={'searchSessionIndicatorPopoverContainer'}
>
<EuiFlexItem grow={true}>
<EuiText size="s" color={'subdued'}>
<p>{popover.text}</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup wrap={true} responsive={false} alignItems={'center'} gutterSize={'s'}>
{popover.primaryAction && (
<EuiFlexItem grow={false}>
<popover.primaryAction {...props} buttonProps={{ size: 'xs', flush: 'both' }} />
</EuiFlexItem>
)}
{popover.primaryAction && popover.secondaryAction && <VerticalDivider />}
{popover.secondaryAction && (
<EuiFlexItem grow={false}>
<popover.secondaryAction {...props} buttonProps={{ size: 'xs', flush: 'both' }} />
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<div data-test-subj="searchSessionIndicatorPopoverContainer">
<EuiText size="s">
<p>{popover.title}</p>
</EuiText>
<EuiSpacer size={'xs'} />
<EuiText size="xs" color={'subdued'}>
<p>{popover.description}</p>
</EuiText>
<EuiSpacer size={'s'} />
<EuiFlexGroup
wrap={true}
responsive={false}
alignItems={'center'}
justifyContent={'flexEnd'}
gutterSize={'s'}
>
{popover.primaryAction && (
<EuiFlexItem grow={false}>
<popover.primaryAction {...props} buttonProps={{ size: 'xs' }} />
</EuiFlexItem>
)}
{popover.secondaryAction && (
<EuiFlexItem grow={false}>
<popover.secondaryAction {...props} buttonProps={{ size: 'xs', flush: 'right' }} />
</EuiFlexItem>
)}
</EuiFlexGroup>
</div>
</EuiPopover>
);
};
});
// React.lazy() needs default:
// eslint-disable-next-line import/no-default-export

View file

@ -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,17 +81,22 @@ export function SearchSessionsProvider({ getService }: FtrProviderContext) {
await this.ensurePopoverClosed();
}
public async refresh() {
log.debug('refresh the status');
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() {
log.debug('ensurePopoverOpened');
const isAlreadyOpen = await testSubjects.exists(SEARCH_SESSIONS_POPOVER_CONTENT_TEST_SUBJ);
@ -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),
]);
}
})();
}

View file

@ -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'));
});
}

View file

@ -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
});
});
}

View file

@ -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);

View file

@ -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'));

View file

@ -31,6 +31,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
retry.tryForTime(10000, async () => {
testSubjects.existOrFail('dashboardLandingPage');
});
await searchSessions.markTourDone();
});
after(async () => {