[Search Sessions] Disable "save session" due to timeout (#90294)

This commit is contained in:
Anton Dosov 2021-02-08 16:20:56 +01:00 committed by GitHub
parent dccea865e4
commit bbda20619e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 276 additions and 100 deletions

View file

@ -6,6 +6,7 @@
*/
import React from 'react';
import moment from 'moment';
import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public';
import { DataPublicPluginSetup, DataPublicPluginStart } from '../../../../src/plugins/data/public';
import { BfetchPublicSetup } from '../../../../src/plugins/bfetch/public';
@ -86,6 +87,9 @@ export class DataEnhancedPlugin
application: core.application,
timeFilter: plugins.data.query.timefilter.timefilter,
storage: this.storage,
disableSaveAfterSessionCompletesTimeout: moment
.duration(this.config.search.sessions.notTouchedTimeout)
.asMilliseconds(),
})
)
),

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React from 'react';
import React, { ReactNode } 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/';
@ -20,6 +20,8 @@ import {
} 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';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from 'react-intl';
const coreStart = coreMock.createStart();
const dataStart = dataPluginMock.createStartContract();
@ -30,6 +32,12 @@ const timeFilter = dataStart.query.timefilter.timefilter as jest.Mocked<Timefilt
timeFilter.getRefreshIntervalUpdate$.mockImplementation(() => refreshInterval$);
timeFilter.getRefreshInterval.mockImplementation(() => refreshInterval$.getValue());
const disableSaveAfterSessionCompletesTimeout = 5 * 60 * 1000;
function Container({ children }: { children?: ReactNode }) {
return <IntlProvider locale="en">{children}</IntlProvider>;
}
beforeEach(() => {
storage = new Storage(new StubBrowserStorage());
refreshInterval$.next({ value: 0, pause: true });
@ -47,8 +55,13 @@ test("shouldn't show indicator in case no active search session", async () => {
application: coreStart.application,
timeFilter,
storage,
disableSaveAfterSessionCompletesTimeout,
});
const { getByTestId, container } = render(<SearchSessionIndicator />);
const { getByTestId, container } = render(
<Container>
<SearchSessionIndicator />
</Container>
);
// make sure `searchSessionIndicator` isn't appearing after some time (lazy-loading)
await expect(
@ -69,8 +82,13 @@ test("shouldn't show indicator in case app hasn't opt-in", async () => {
application: coreStart.application,
timeFilter,
storage,
disableSaveAfterSessionCompletesTimeout,
});
const { getByTestId, container } = render(<SearchSessionIndicator />);
const { getByTestId, container } = render(
<Container>
<SearchSessionIndicator />
</Container>
);
sessionService.isSessionStorageReady.mockImplementation(() => false);
// make sure `searchSessionIndicator` isn't appearing after some time (lazy-loading)
@ -93,8 +111,13 @@ test('should show indicator in case there is an active search session', async ()
application: coreStart.application,
timeFilter,
storage,
disableSaveAfterSessionCompletesTimeout,
});
const { getByTestId } = render(<SearchSessionIndicator />);
const { getByTestId } = render(
<Container>
<SearchSessionIndicator />
</Container>
);
await waitFor(() => getByTestId('searchSessionIndicator'));
});
@ -118,13 +141,20 @@ test('should be disabled in case uiConfig says so ', async () => {
application: coreStart.application,
timeFilter,
storage,
disableSaveAfterSessionCompletesTimeout,
});
render(<SearchSessionIndicator />);
render(
<Container>
<SearchSessionIndicator />
</Container>
);
await waitFor(() => screen.getByTestId('searchSessionIndicator'));
expect(screen.getByTestId('searchSessionIndicator').querySelector('button')).toBeDisabled();
await userEvent.click(screen.getByLabelText('Search session loading'));
expect(screen.getByRole('button', { name: 'Save session' })).toBeDisabled();
});
test('should be disabled during auto-refresh', async () => {
@ -135,19 +165,82 @@ test('should be disabled during auto-refresh', async () => {
application: coreStart.application,
timeFilter,
storage,
disableSaveAfterSessionCompletesTimeout,
});
render(<SearchSessionIndicator />);
render(
<Container>
<SearchSessionIndicator />
</Container>
);
await waitFor(() => screen.getByTestId('searchSessionIndicator'));
expect(screen.getByTestId('searchSessionIndicator').querySelector('button')).not.toBeDisabled();
await userEvent.click(screen.getByLabelText('Search session loading'));
expect(screen.getByRole('button', { name: 'Save session' })).not.toBeDisabled();
act(() => {
refreshInterval$.next({ value: 0, pause: false });
});
expect(screen.getByTestId('searchSessionIndicator').querySelector('button')).toBeDisabled();
expect(screen.getByRole('button', { name: 'Save session' })).toBeDisabled();
});
describe('Completed inactivity', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
test('save should be disabled after completed and timeout', async () => {
const state$ = new BehaviorSubject(SearchSessionState.Loading);
const SearchSessionIndicator = createConnectedSearchSessionIndicator({
sessionService: { ...sessionService, state$ },
application: coreStart.application,
timeFilter,
storage,
disableSaveAfterSessionCompletesTimeout,
});
render(
<Container>
<SearchSessionIndicator />
</Container>
);
await waitFor(() => screen.getByTestId('searchSessionIndicator'));
await userEvent.click(screen.getByLabelText('Search session loading'));
expect(screen.getByRole('button', { name: 'Save session' })).not.toBeDisabled();
act(() => {
jest.advanceTimersByTime(5 * 60 * 1000);
});
expect(screen.getByRole('button', { name: 'Save session' })).not.toBeDisabled();
act(() => {
state$.next(SearchSessionState.Completed);
});
expect(screen.getByRole('button', { name: 'Save session' })).not.toBeDisabled();
act(() => {
jest.advanceTimersByTime(2.5 * 60 * 1000);
});
expect(screen.getByRole('button', { name: 'Save session' })).not.toBeDisabled();
act(() => {
jest.advanceTimersByTime(2.5 * 60 * 1000);
});
expect(screen.getByRole('button', { name: 'Save session' })).toBeDisabled();
});
});
describe('tour steps', () => {
@ -167,8 +260,13 @@ describe('tour steps', () => {
application: coreStart.application,
timeFilter,
storage,
disableSaveAfterSessionCompletesTimeout,
});
const rendered = render(<SearchSessionIndicator />);
const rendered = render(
<Container>
<SearchSessionIndicator />
</Container>
);
await waitFor(() => rendered.getByTestId('searchSessionIndicator'));
@ -199,8 +297,13 @@ describe('tour steps', () => {
application: coreStart.application,
timeFilter,
storage,
disableSaveAfterSessionCompletesTimeout,
});
const rendered = render(<SearchSessionIndicator />);
const rendered = render(
<Container>
<SearchSessionIndicator />
</Container>
);
const searchSessionIndicator = await rendered.findByTestId('searchSessionIndicator');
expect(searchSessionIndicator).toBeTruthy();
@ -225,8 +328,13 @@ describe('tour steps', () => {
application: coreStart.application,
timeFilter,
storage,
disableSaveAfterSessionCompletesTimeout,
});
const rendered = render(<SearchSessionIndicator />);
const rendered = render(
<Container>
<SearchSessionIndicator />
</Container>
);
await waitFor(() => rendered.getByTestId('searchSessionIndicator'));
expect(screen.getByTestId('searchSessionIndicatorPopoverContainer')).toBeInTheDocument();
@ -242,8 +350,13 @@ describe('tour steps', () => {
application: coreStart.application,
timeFilter,
storage,
disableSaveAfterSessionCompletesTimeout,
});
const rendered = render(<SearchSessionIndicator />);
const rendered = render(
<Container>
<SearchSessionIndicator />
</Container>
);
await waitFor(() => rendered.getByTestId('searchSessionIndicator'));

View file

@ -5,9 +5,9 @@
* 2.0.
*/
import React, { useRef } from 'react';
import { debounce, distinctUntilChanged, map } from 'rxjs/operators';
import { timer } from 'rxjs';
import React, { useCallback, useState } from 'react';
import { debounce, distinctUntilChanged, map, mapTo, switchMap } from 'rxjs/operators';
import { merge, of, timer } from 'rxjs';
import useObservable from 'react-use/lib/useObservable';
import { i18n } from '@kbn/i18n';
import { SearchSessionIndicator, SearchSessionIndicatorRef } from '../search_session_indicator';
@ -26,6 +26,11 @@ export interface SearchSessionIndicatorDeps {
timeFilter: TimefilterContract;
application: ApplicationStart;
storage: IStorageWrapper;
/**
* Controls for how long we allow to save a session,
* after the last search in the session has completed
*/
disableSaveAfterSessionCompletesTimeout: number;
}
export const createConnectedSearchSessionIndicator = ({
@ -33,6 +38,7 @@ export const createConnectedSearchSessionIndicator = ({
application,
timeFilter,
storage,
disableSaveAfterSessionCompletesTimeout,
}: SearchSessionIndicatorDeps): React.FC => {
const isAutoRefreshEnabled = () => !timeFilter.getRefreshInterval().pause;
const isAutoRefreshEnabled$ = timeFilter
@ -43,60 +49,104 @@ export const createConnectedSearchSessionIndicator = ({
debounce((_state) => timer(_state === SearchSessionState.None ? 50 : 300)) // switch to None faster to quickly remove indicator when navigating away
);
const disableSaveAfterSessionCompleteTimedOut$ = sessionService.state$.pipe(
switchMap((_state) =>
_state === SearchSessionState.Completed
? merge(of(false), timer(disableSaveAfterSessionCompletesTimeout).pipe(mapTo(true)))
: of(false)
),
distinctUntilChanged()
);
return () => {
const ref = useRef<SearchSessionIndicatorRef>(null);
const state = useObservable(debouncedSessionServiceState$, SearchSessionState.None);
const autoRefreshEnabled = useObservable(isAutoRefreshEnabled$, isAutoRefreshEnabled());
const isDisabledByApp = sessionService.getSearchSessionIndicatorUiConfig().isDisabled();
const isSaveDisabledByApp = sessionService.getSearchSessionIndicatorUiConfig().isDisabled();
const disableSaveAfterSessionCompleteTimedOut = useObservable(
disableSaveAfterSessionCompleteTimedOut$,
false
);
const [
searchSessionIndicator,
setSearchSessionIndicator,
] = useState<SearchSessionIndicatorRef | null>(null);
const searchSessionIndicatorRef = useCallback((ref: SearchSessionIndicatorRef) => {
if (ref !== null) {
setSearchSessionIndicator(ref);
}
}, []);
let disabled = false;
let disabledReasonText: string = '';
let saveDisabled = false;
let saveDisabledReasonText: string = '';
if (autoRefreshEnabled) {
disabled = true;
disabledReasonText = i18n.translate(
saveDisabled = true;
saveDisabledReasonText = i18n.translate(
'xpack.data.searchSessionIndicator.disabledDueToAutoRefreshMessage',
{
defaultMessage: 'Search sessions are not available when auto refresh is enabled.',
defaultMessage: 'Saving search session is not available when auto refresh is enabled.',
}
);
}
if (disableSaveAfterSessionCompleteTimedOut) {
saveDisabled = true;
saveDisabledReasonText = i18n.translate(
'xpack.data.searchSessionIndicator.disabledDueToTimeoutMessage',
{
defaultMessage: 'Search session results expired.',
}
);
}
if (isSaveDisabledByApp.disabled) {
saveDisabled = true;
saveDisabledReasonText = isSaveDisabledByApp.reasonText;
}
const { markOpenedDone, markRestoredDone } = useSearchSessionTour(
storage,
ref,
searchSessionIndicator,
state,
disabled
saveDisabled
);
if (isDisabledByApp.disabled) {
disabled = true;
disabledReasonText = isDisabledByApp.reasonText;
}
const onOpened = useCallback(
(openedState: SearchSessionState) => {
markOpenedDone();
if (openedState === SearchSessionState.Restored) {
markRestoredDone();
}
},
[markOpenedDone, markRestoredDone]
);
const onContinueInBackground = useCallback(() => {
if (saveDisabled) return;
sessionService.save();
}, [saveDisabled]);
const onSaveResults = useCallback(() => {
if (saveDisabled) return;
sessionService.save();
}, [saveDisabled]);
const onCancel = useCallback(() => {
sessionService.cancel();
}, []);
if (!sessionService.isSessionStorageReady()) return null;
return (
<RedirectAppLinks application={application}>
<SearchSessionIndicator
ref={ref}
ref={searchSessionIndicatorRef}
state={state}
onContinueInBackground={() => {
sessionService.save();
}}
onSaveResults={() => {
sessionService.save();
}}
onCancel={() => {
sessionService.cancel();
}}
disabled={disabled}
disabledReasonText={disabledReasonText}
onOpened={(openedState) => {
markOpenedDone();
if (openedState === SearchSessionState.Restored) {
markRestoredDone();
}
}}
saveDisabled={saveDisabled}
saveDisabledReasonText={saveDisabledReasonText}
onContinueInBackground={onContinueInBackground}
onSaveResults={onSaveResults}
onCancel={onCancel}
onOpened={onOpened}
/>
</RedirectAppLinks>
);

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { MutableRefObject, useCallback, useEffect } from 'react';
import { 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';
@ -16,7 +16,7 @@ export const TOUR_RESTORE_STEP_KEY = `data.searchSession.tour.restore`;
export function useSearchSessionTour(
storage: IStorageWrapper,
searchSessionIndicatorRef: MutableRefObject<SearchSessionIndicatorRef | null>,
searchSessionIndicatorRef: SearchSessionIndicatorRef | null,
state: SearchSessionState,
searchSessionsDisabled: boolean
) {
@ -30,19 +30,20 @@ export function useSearchSessionTour(
useEffect(() => {
if (searchSessionsDisabled) return;
if (!searchSessionIndicatorRef) return;
let timeoutHandle: number;
if (state === SearchSessionState.Loading) {
if (!safeHas(storage, TOUR_TAKING_TOO_LONG_STEP_KEY)) {
timeoutHandle = window.setTimeout(() => {
safeOpen(searchSessionIndicatorRef);
searchSessionIndicatorRef.openPopover();
}, TOUR_TAKING_TOO_LONG_TIMEOUT);
}
}
if (state === SearchSessionState.Restored) {
if (!safeHas(storage, TOUR_RESTORE_STEP_KEY)) {
safeOpen(searchSessionIndicatorRef);
searchSessionIndicatorRef.openPopover();
}
}
@ -79,15 +80,3 @@ function safeSet(storage: IStorageWrapper, key: string) {
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

@ -33,9 +33,9 @@ storiesOf('components/SearchSessionIndicator', module).add('default', () => (
<div>
<SearchSessionIndicator
state={SearchSessionState.Completed}
disabled={true}
disabledReasonText={
'Send to background capability is unavailable when auto-refresh is enabled'
saveDisabled={true}
saveDisabledReasonText={
'Search results have expired and it is no longer possible to save this search session'
}
/>
</div>

View file

@ -108,11 +108,21 @@ test('Canceled state', async () => {
});
test('Disabled state', async () => {
render(
const { rerender } = render(
<Container>
<SearchSessionIndicator state={SearchSessionState.Loading} disabled={true} />
<SearchSessionIndicator state={SearchSessionState.Loading} saveDisabled={true} />
</Container>
);
expect(screen.getByTestId('searchSessionIndicator').querySelector('button')).toBeDisabled();
await userEvent.click(screen.getByLabelText('Search session loading'));
expect(screen.getByRole('button', { name: 'Save session' })).toBeDisabled();
rerender(
<Container>
<SearchSessionIndicator state={SearchSessionState.Completed} saveDisabled={true} />
</Container>
);
expect(screen.getByRole('button', { name: 'Save session' })).toBeDisabled();
});

View file

@ -31,8 +31,10 @@ export interface SearchSessionIndicatorProps {
onCancel?: () => void;
viewSearchSessionsLink?: string;
onSaveResults?: () => void;
disabled?: boolean;
disabledReasonText?: string;
saveDisabled?: boolean;
saveDisabledReasonText?: string;
onOpened?: (openedState: SearchSessionState) => void;
}
@ -55,17 +57,22 @@ const CancelButton = ({ onCancel = () => {}, buttonProps = {} }: ActionButtonPro
const ContinueInBackgroundButton = ({
onContinueInBackground = () => {},
buttonProps = {},
saveDisabled = false,
saveDisabledReasonText,
}: ActionButtonProps) => (
<EuiButtonEmpty
onClick={onContinueInBackground}
data-test-subj={'searchSessionIndicatorContinueInBackgroundBtn'}
{...buttonProps}
>
<FormattedMessage
id="xpack.data.searchSessionIndicator.continueInBackgroundButtonText"
defaultMessage="Save session"
/>
</EuiButtonEmpty>
<EuiToolTip content={saveDisabledReasonText}>
<EuiButtonEmpty
onClick={onContinueInBackground}
data-test-subj={'searchSessionIndicatorContinueInBackgroundBtn'}
isDisabled={saveDisabled}
{...buttonProps}
>
<FormattedMessage
id="xpack.data.searchSessionIndicator.continueInBackgroundButtonText"
defaultMessage="Save session"
/>
</EuiButtonEmpty>
</EuiToolTip>
);
const ViewAllSearchSessionsButton = ({
@ -84,17 +91,25 @@ const ViewAllSearchSessionsButton = ({
</EuiButtonEmpty>
);
const SaveButton = ({ onSaveResults = () => {}, buttonProps = {} }: ActionButtonProps) => (
<EuiButtonEmpty
onClick={onSaveResults}
data-test-subj={'searchSessionIndicatorSaveBtn'}
{...buttonProps}
>
<FormattedMessage
id="xpack.data.searchSessionIndicator.saveButtonText"
defaultMessage="Save session"
/>
</EuiButtonEmpty>
const SaveButton = ({
onSaveResults = () => {},
buttonProps = {},
saveDisabled = false,
saveDisabledReasonText,
}: ActionButtonProps) => (
<EuiToolTip content={saveDisabledReasonText}>
<EuiButtonEmpty
onClick={onSaveResults}
data-test-subj={'searchSessionIndicatorSaveBtn'}
isDisabled={saveDisabled}
{...buttonProps}
>
<FormattedMessage
id="xpack.data.searchSessionIndicator.saveButtonText"
defaultMessage="Save session"
/>
</EuiButtonEmpty>
</EuiToolTip>
);
const searchSessionIndicatorViewStateToProps: {
@ -325,19 +340,16 @@ export const SearchSessionIndicator = React.forwardRef<
className="searchSessionIndicator"
data-test-subj={'searchSessionIndicator'}
data-state={props.state}
data-save-disabled={props.saveDisabled ?? false}
panelClassName={'searchSessionIndicator__panel'}
repositionOnScroll={true}
button={
<EuiToolTip
content={props.disabled ? props.disabledReasonText : button.tooltipText}
delay={props.disabled ? 'regular' : 'long'}
>
<EuiToolTip content={button.tooltipText} delay={'long'}>
<EuiButtonIcon
color={button.color}
aria-label={button['aria-label']}
iconType={button.iconType}
onClick={onButtonClick}
disabled={props.disabled}
/>
</EuiToolTip>
}

View file

@ -47,9 +47,7 @@ export function SearchSessionsProvider({ getService }: FtrProviderContext) {
public async disabledOrFail() {
await this.exists();
await expect(await (await (await this.find()).findByTagName('button')).isEnabled()).to.be(
false
);
await expect(await (await this.find()).getAttribute('data-save-disabled')).to.be('true');
}
public async expectState(state: SessionStateType) {