Skip to content

Commit

Permalink
[Discover] Support storing time with saved searches (#138377)
Browse files Browse the repository at this point in the history
* [Discover] Implement UI for storing time with a saved search

* [Discover] Save time range data with a saved search

* [Discover] Improve updating of values

* [Discover] Restore time range after loading a saved search

* [Discover] Add time range validation

* [Discover] Add refresh interval validation

* [Discover] Update how saved search gets restored

* [Discover] Improve tests

* [Discover] Update tests

* [Discover] Improve type imports

* [Discover] Update copy

* [Discover] Fix types after the merge

* [Discover] Update test name

* [Discover] Fix types

* [Discover] Update mapping

* [Discover] Update mapping

* Explicitly set field limit for .kibana_ esArchives

Co-authored-by: Kibana Machine <[email protected]>
Co-authored-by: Rudolf Meijering <[email protected]>
  • Loading branch information
3 people authored Aug 23, 2022
1 parent ac0688b commit 1a70f6f
Show file tree
Hide file tree
Showing 22 changed files with 351 additions and 17 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
* Side Public License, v 1.
*/

import React from 'react';
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFormRow, EuiSwitch } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { SavedObjectSaveModal, showSaveModal, OnSaveProps } from '@kbn/saved-objects-plugin/public';
import { DataView } from '@kbn/data-views-plugin/public';
import { SavedSearch, SaveSavedSearchOptions } from '@kbn/saved-search-plugin/public';
Expand Down Expand Up @@ -108,20 +110,24 @@ export async function onSaveSearch({
const onSave = async ({
newTitle,
newCopyOnSave,
newTimeRestore,
newDescription,
isTitleDuplicateConfirmed,
onTitleDuplicate,
}: {
newTitle: string;
newTimeRestore: boolean;
newCopyOnSave: boolean;
newDescription: string;
isTitleDuplicateConfirmed: boolean;
onTitleDuplicate: () => void;
}) => {
const currentTitle = savedSearch.title;
const currentTimeRestore = savedSearch.timeRestore;
const currentRowsPerPage = savedSearch.rowsPerPage;
savedSearch.title = newTitle;
savedSearch.description = newDescription;
savedSearch.timeRestore = newTimeRestore;
savedSearch.rowsPerPage = uiSettings.get(DOC_TABLE_LEGACY)
? currentRowsPerPage
: state.appStateContainer.getState().rowsPerPage;
Expand All @@ -143,6 +149,7 @@ export async function onSaveSearch({
// If the save wasn't successful, put the original values back.
if (!response.id || response.error) {
savedSearch.title = currentTitle;
savedSearch.timeRestore = currentTimeRestore;
savedSearch.rowsPerPage = currentRowsPerPage;
} else {
state.resetInitialAppState();
Expand All @@ -156,6 +163,7 @@ export async function onSaveSearch({
title={savedSearch.title ?? ''}
showCopyOnSave={!!savedSearch.id}
description={savedSearch.description}
timeRestore={savedSearch.timeRestore}
onSave={onSave}
onClose={onClose ?? (() => {})}
/>
Expand All @@ -167,13 +175,42 @@ const SaveSearchObjectModal: React.FC<{
title: string;
showCopyOnSave: boolean;
description?: string;
onSave: (props: OnSaveProps & { newRowsPerPage?: number }) => void;
timeRestore?: boolean;
onSave: (props: OnSaveProps & { newTimeRestore: boolean }) => void;
onClose: () => void;
}> = ({ title, description, showCopyOnSave, onSave, onClose }) => {
}> = ({ title, description, showCopyOnSave, timeRestore: savedTimeRestore, onSave, onClose }) => {
const [timeRestore, setTimeRestore] = useState<boolean>(savedTimeRestore || false);

const onModalSave = (params: OnSaveProps) => {
onSave(params);
onSave({
...params,
newTimeRestore: timeRestore,
});
};

const options = (
<EuiFormRow
helpText={
<FormattedMessage
id="discover.topNav.saveModal.storeTimeWithSearchToggleDescription"
defaultMessage="Update the time filter and refresh interval to the current selection when using this search."
/>
}
>
<EuiSwitch
data-test-subj="storeTimeWithSearch"
checked={timeRestore}
onChange={(event) => setTimeRestore(event.target.checked)}
label={
<FormattedMessage
id="discover.topNav.saveModal.storeTimeWithSearchToggleLabel"
defaultMessage="Store time with saved search"
/>
}
/>
</EuiFormRow>
);

return (
<SavedObjectSaveModal
title={title}
Expand All @@ -183,6 +220,7 @@ const SaveSearchObjectModal: React.FC<{
defaultMessage: 'search',
})}
showDescription={true}
options={options}
onSave={onModalSave}
onClose={onClose}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { LoadingIndicator } from '../../components/common/loading_indicator';
import { DiscoverError } from '../../components/common/error_alert';
import { useDiscoverServices } from '../../hooks/use_discover_services';
import { getUrlTracker } from '../../kibana_services';
import { restoreStateFromSavedSearch } from '../../services/saved_searches/restore_from_saved_search';

const DiscoverMainAppMemoized = memo(DiscoverMainApp);

Expand Down Expand Up @@ -129,6 +130,11 @@ export function DiscoverMainRoute(props: Props) {
currentSavedSearch.searchSource.setField('index', currentDataView);
}

restoreStateFromSavedSearch({
savedSearch: currentSavedSearch,
timefilter: services.timefilter,
});

setSavedSearch(currentSavedSearch);

if (currentSavedSearch.id) {
Expand Down Expand Up @@ -163,8 +169,9 @@ export function DiscoverMainRoute(props: Props) {
}
}, [
id,
services.data.search,
services.data,
services.spaces,
services.timefilter,
core.savedObjects.client,
core.application.navigateToApp,
core.theme,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { FetchStatus } from '../../types';
import { getDataViewAppState } from '../utils/get_switch_data_view_app_state';
import { SortPairArr } from '../../../components/doc_table/utils/get_sort';
import { DataTableRecord } from '../../../types';
import { restoreStateFromSavedSearch } from '../../../services/saved_searches/restore_from_saved_search';

const MAX_NUM_OF_COLUMNS = 50;

Expand Down Expand Up @@ -193,6 +194,12 @@ export function useDiscoverState({
savedSearch: newSavedSearch,
storage,
});

restoreStateFromSavedSearch({
savedSearch: newSavedSearch,
timefilter: services.timefilter,
});

await stateContainer.replaceUrlAppState(newAppState);
setState(newAppState);
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,20 @@ export async function persistSavedSearch(
savedSearch.isTextBasedQuery = isTextBasedQuery;
}

const { from, to } = services.timefilter.getTime();
const refreshInterval = services.timefilter.getRefreshInterval();
savedSearch.timeRange =
savedSearch.timeRestore || savedSearch.timeRange
? {
from,
to,
}
: undefined;
savedSearch.refreshInterval =
savedSearch.timeRestore || savedSearch.refreshInterval
? { value: refreshInterval.value, pause: refreshInterval.pause }
: undefined;

try {
const id = await saveSavedSearch(savedSearch, saveOptions, services.core.savedObjects.client);
if (id) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
* Side Public License, v 1.
*/

import dateMath from '@kbn/datemath';
import { i18n } from '@kbn/i18n';
import { ToastsStart } from '@kbn/core/public';
import { isTimeRangeValid } from '../../../utils/validate_time';

/**
* Validates a given time filter range, provided by URL or UI
Expand All @@ -18,9 +18,7 @@ export function validateTimeRange(
{ from, to }: { from: string; to: string },
toastNotifications: ToastsStart
): boolean {
const fromMoment = dateMath.parse(from);
const toMoment = dateMath.parse(to);
if (!fromMoment || !toMoment || !fromMoment.isValid() || !toMoment.isValid()) {
if (!isTimeRangeValid({ from, to })) {
toastNotifications.addDanger({
title: i18n.translate('discover.notifications.invalidTimeRangeTitle', {
defaultMessage: `Invalid time range`,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import type { TimefilterContract } from '@kbn/data-plugin/public';
import type { TimeRange, RefreshInterval } from '@kbn/data-plugin/common';
import type { SavedSearch } from '@kbn/saved-search-plugin/public';
import { restoreStateFromSavedSearch } from './restore_from_saved_search';

describe('discover restore state from saved search', () => {
let timefilterMock: TimefilterContract;
const timeRange: TimeRange = {
from: 'now-30m',
to: 'now',
};
const refreshInterval: RefreshInterval = {
value: 5000,
pause: false,
};

beforeEach(() => {
timefilterMock = {
setTime: jest.fn(),
setRefreshInterval: jest.fn(),
} as unknown as TimefilterContract;
});

test('should not update timefilter if attributes are not set', async () => {
restoreStateFromSavedSearch({
savedSearch: {} as SavedSearch,
timefilter: timefilterMock,
});

expect(timefilterMock.setTime).not.toHaveBeenCalled();
expect(timefilterMock.setRefreshInterval).not.toHaveBeenCalled();
});

test('should not update timefilter if timeRestore is disabled', async () => {
restoreStateFromSavedSearch({
savedSearch: {
timeRestore: false,
timeRange,
refreshInterval,
} as SavedSearch,
timefilter: timefilterMock,
});

expect(timefilterMock.setTime).not.toHaveBeenCalled();
expect(timefilterMock.setRefreshInterval).not.toHaveBeenCalled();
});

test('should update timefilter if timeRestore is enabled', async () => {
restoreStateFromSavedSearch({
savedSearch: {
timeRestore: true,
timeRange,
refreshInterval,
} as SavedSearch,
timefilter: timefilterMock,
});

expect(timefilterMock.setTime).toHaveBeenCalledWith(timeRange);
expect(timefilterMock.setRefreshInterval).toHaveBeenCalledWith(refreshInterval);
});

test('should not update timefilter if attributes are missing', async () => {
restoreStateFromSavedSearch({
savedSearch: {
timeRestore: true,
} as SavedSearch,
timefilter: timefilterMock,
});

expect(timefilterMock.setTime).not.toHaveBeenCalled();
expect(timefilterMock.setRefreshInterval).not.toHaveBeenCalled();
});

test('should not update timefilter if attributes are invalid', async () => {
restoreStateFromSavedSearch({
savedSearch: {
timeRestore: true,
timeRange: {
from: 'test',
to: 'now',
},
refreshInterval: {
pause: false,
value: -500,
},
} as SavedSearch,
timefilter: timefilterMock,
});

expect(timefilterMock.setTime).not.toHaveBeenCalled();
expect(timefilterMock.setRefreshInterval).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import type { TimefilterContract } from '@kbn/data-plugin/public';
import type { SavedSearch } from '@kbn/saved-search-plugin/public';
import { isRefreshIntervalValid, isTimeRangeValid } from '../../utils/validate_time';

export const restoreStateFromSavedSearch = ({
savedSearch,
timefilter,
}: {
savedSearch: SavedSearch;
timefilter: TimefilterContract;
}) => {
if (!savedSearch) {
return;
}

if (savedSearch.timeRestore && savedSearch.timeRange && isTimeRangeValid(savedSearch.timeRange)) {
timefilter.setTime(savedSearch.timeRange);
}
if (
savedSearch.timeRestore &&
savedSearch.refreshInterval &&
isRefreshIntervalValid(savedSearch.refreshInterval)
) {
timefilter.setRefreshInterval(savedSearch.refreshInterval);
}
};
38 changes: 38 additions & 0 deletions src/plugins/discover/public/utils/validate_time.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import type { RefreshInterval, TimeRange } from '@kbn/data-plugin/common';
import { isTimeRangeValid, isRefreshIntervalValid } from './validate_time';

describe('discover validate time', () => {
test('should validate time ranges correctly', async () => {
expect(isTimeRangeValid({ from: '2020-06-02T13:36:13.689Z', to: 'now' })).toEqual(true);
expect(isTimeRangeValid({ from: 'now', to: 'now+1h' })).toEqual(true);
expect(isTimeRangeValid({ from: '', to: '' })).toEqual(false);
expect(isTimeRangeValid({} as unknown as TimeRange)).toEqual(false);
expect(isTimeRangeValid(undefined)).toEqual(false);
});

test('should validate that refresh interval is valid', async () => {
expect(isRefreshIntervalValid({ value: 5000, pause: false })).toEqual(true);
expect(isRefreshIntervalValid({ value: 0, pause: false })).toEqual(true);
expect(isRefreshIntervalValid({ value: 4000, pause: true })).toEqual(true);
});

test('should validate that refresh interval is invalid', async () => {
expect(isRefreshIntervalValid({ value: -5000, pause: false })).toEqual(false);
expect(
isRefreshIntervalValid({ value: 'test', pause: false } as unknown as RefreshInterval)
).toEqual(false);
expect(
isRefreshIntervalValid({ value: 4000, pause: 'test' } as unknown as RefreshInterval)
).toEqual(false);
expect(isRefreshIntervalValid({} as unknown as RefreshInterval)).toEqual(false);
expect(isRefreshIntervalValid(undefined)).toEqual(false);
});
});
Loading

0 comments on commit 1a70f6f

Please sign in to comment.