[Discover] Support storing time with saved searches (#138377)

* [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 <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Rudolf Meijering <skaapgif@gmail.com>
This commit is contained in:
Julia Rechkunova 2022-08-23 10:53:55 +02:00 committed by GitHub
parent ac0688b90f
commit 1a70f6fd37
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 351 additions and 17 deletions

View file

@ -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';
@ -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;
@ -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();
@ -156,6 +163,7 @@ export async function onSaveSearch({
title={savedSearch.title ?? ''}
showCopyOnSave={!!savedSearch.id}
description={savedSearch.description}
timeRestore={savedSearch.timeRestore}
onSave={onSave}
onClose={onClose ?? (() => {})}
/>
@ -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}
@ -183,6 +220,7 @@ const SaveSearchObjectModal: React.FC<{
defaultMessage: 'search',
})}
showDescription={true}
options={options}
onSave={onModalSave}
onClose={onClose}
/>

View file

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

View file

@ -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;
@ -193,6 +194,12 @@ export function useDiscoverState({
savedSearch: newSavedSearch,
storage,
});
restoreStateFromSavedSearch({
savedSearch: newSavedSearch,
timefilter: services.timefilter,
});
await stateContainer.replaceUrlAppState(newAppState);
setState(newAppState);
},

View file

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

View file

@ -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
@ -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`,

View file

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

View file

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

View file

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

View file

@ -0,0 +1,30 @@
/*
* 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 dateMath from '@kbn/datemath';
import type { RefreshInterval } from '@kbn/data-plugin/common';
export function isTimeRangeValid(timeRange?: { from: string; to: string }): boolean {
if (!timeRange?.from || !timeRange?.to) {
return false;
}
const fromMoment = dateMath.parse(timeRange.from);
const toMoment = dateMath.parse(timeRange.to);
return Boolean(fromMoment && toMoment && fromMoment.isValid() && toMoment.isValid());
}
export function isRefreshIntervalValid(refreshInterval?: RefreshInterval): boolean {
if (!refreshInterval) {
return false;
}
return (
typeof refreshInterval?.value === 'number' &&
refreshInterval?.value >= 0 &&
typeof refreshInterval?.pause === 'boolean'
);
}

View file

@ -105,6 +105,7 @@ describe('getSavedSearch', () => {
"hideChart": false,
"id": "ccf1af80-2297-11ec-86e0-1155ffb9c7a7",
"isTextBasedQuery": undefined,
"refreshInterval": undefined,
"rowHeight": undefined,
"rowsPerPage": undefined,
"searchSource": Object {
@ -145,6 +146,8 @@ describe('getSavedSearch', () => {
"desc",
],
],
"timeRange": undefined,
"timeRestore": undefined,
"title": "test1",
"viewMode": undefined,
}
@ -198,6 +201,7 @@ describe('getSavedSearch', () => {
"hideChart": true,
"id": "ccf1af80-2297-11ec-86e0-1155ffb9c7a7",
"isTextBasedQuery": true,
"refreshInterval": undefined,
"rowHeight": undefined,
"rowsPerPage": undefined,
"searchSource": Object {
@ -238,6 +242,8 @@ describe('getSavedSearch', () => {
"desc",
],
],
"timeRange": undefined,
"timeRestore": undefined,
"title": "test2",
"viewMode": undefined,
}

View file

@ -92,6 +92,7 @@ describe('saveSavedSearch', () => {
kibanaSavedObjectMeta: { searchSourceJSON: '{}' },
sort: [],
title: 'title',
timeRestore: false,
},
{ references: [] }
);
@ -112,6 +113,7 @@ describe('saveSavedSearch', () => {
kibanaSavedObjectMeta: { searchSourceJSON: '{}' },
sort: [],
title: 'title',
timeRestore: false,
},
{ references: [] }
);

View file

@ -43,6 +43,7 @@ describe('saved_searches_utils', () => {
"hideChart": true,
"id": "id",
"isTextBasedQuery": false,
"refreshInterval": undefined,
"rowHeight": undefined,
"rowsPerPage": undefined,
"searchSource": SearchSource {
@ -66,6 +67,8 @@ describe('saved_searches_utils', () => {
},
"sharingSavedObjectProps": Object {},
"sort": Array [],
"timeRange": undefined,
"timeRestore": undefined,
"title": "saved search",
"viewMode": undefined,
}
@ -123,6 +126,7 @@ describe('saved_searches_utils', () => {
"kibanaSavedObjectMeta": Object {
"searchSourceJSON": "{}",
},
"refreshInterval": undefined,
"rowHeight": undefined,
"rowsPerPage": undefined,
"sort": Array [
@ -131,6 +135,8 @@ describe('saved_searches_utils', () => {
"asc",
],
],
"timeRange": undefined,
"timeRestore": false,
"title": "title",
"viewMode": undefined,
}

View file

@ -43,6 +43,9 @@ export const fromSavedSearchAttributes = (
hideAggregatedPreview: attributes.hideAggregatedPreview,
rowHeight: attributes.rowHeight,
isTextBasedQuery: attributes.isTextBasedQuery,
timeRestore: attributes.timeRestore,
timeRange: attributes.timeRange,
refreshInterval: attributes.refreshInterval,
rowsPerPage: attributes.rowsPerPage,
});
@ -61,5 +64,8 @@ export const toSavedSearchAttributes = (
hideAggregatedPreview: savedSearch.hideAggregatedPreview,
rowHeight: savedSearch.rowHeight,
isTextBasedQuery: savedSearch.isTextBasedQuery ?? false,
timeRestore: savedSearch.timeRestore ?? false,
timeRange: savedSearch.timeRange,
refreshInterval: savedSearch.refreshInterval,
rowsPerPage: savedSearch.rowsPerPage,
});

View file

@ -7,7 +7,7 @@
*/
import type { ResolvedSimpleSavedObject } from '@kbn/core/public';
import type { ISearchSource } from '@kbn/data-plugin/public';
import type { ISearchSource, RefreshInterval, TimeRange } from '@kbn/data-plugin/common';
export enum VIEW_MODE {
DOCUMENT_LEVEL = 'documents',
@ -39,6 +39,11 @@ export interface SavedSearchAttributes {
viewMode?: VIEW_MODE;
hideAggregatedPreview?: boolean;
rowHeight?: number;
timeRestore?: boolean;
timeRange?: TimeRange;
refreshInterval?: RefreshInterval;
rowsPerPage?: number;
}
@ -67,5 +72,11 @@ export interface SavedSearch {
hideAggregatedPreview?: boolean;
rowHeight?: number;
isTextBasedQuery?: boolean;
// for restoring time range with a saved search
timeRestore?: boolean;
timeRange?: TimeRange;
refreshInterval?: RefreshInterval;
rowsPerPage?: number;
}

View file

@ -51,6 +51,21 @@ export function getSavedSearchObjectType(
grid: { type: 'object', enabled: false },
version: { type: 'integer' },
rowHeight: { type: 'text' },
timeRestore: { type: 'boolean', index: false, doc_values: false },
timeRange: {
dynamic: false,
properties: {
from: { type: 'keyword', index: false, doc_values: false },
to: { type: 'keyword', index: false, doc_values: false },
},
},
refreshInterval: {
dynamic: false,
properties: {
pause: { type: 'boolean', index: false, doc_values: false },
value: { type: 'integer', index: false, doc_values: false },
},
},
rowsPerPage: { type: 'integer', index: false, doc_values: false },
},
},

View file

@ -423,7 +423,10 @@
"number_of_shards": "1",
"priority": "10",
"refresh_interval": "1s",
"routing_partition_size": "1"
"routing_partition_size": "1",
"mapping": {
"total_fields": { "limit": 1500 }
}
}
}
}

View file

@ -462,7 +462,10 @@
"number_of_shards": "1",
"priority": "10",
"refresh_interval": "1s",
"routing_partition_size": "1"
"routing_partition_size": "1",
"mapping": {
"total_fields": { "limit": 1500 }
}
}
}
}

View file

@ -470,7 +470,10 @@
"number_of_shards": "1",
"priority": "10",
"refresh_interval": "1s",
"routing_partition_size": "1"
"routing_partition_size": "1",
"mapping": {
"total_fields": { "limit": 1500 }
}
}
}
}

View file

@ -462,7 +462,10 @@
"number_of_shards": "1",
"priority": "10",
"refresh_interval": "1s",
"routing_partition_size": "1"
"routing_partition_size": "1",
"mapping": {
"total_fields": { "limit": 1500 }
}
}
}
}

View file

@ -470,7 +470,10 @@
"number_of_shards": "1",
"priority": "10",
"refresh_interval": "1s",
"routing_partition_size": "1"
"routing_partition_size": "1",
"mapping": {
"total_fields": { "limit": 1500 }
}
}
}
}

View file

@ -24,7 +24,10 @@
"index": {
"auto_expand_replicas": "0-1",
"number_of_replicas": "0",
"number_of_shards": "1"
"number_of_shards": "1",
"mapping": {
"total_fields": { "limit": 1500 }
}
}
}
}

View file

@ -13,7 +13,10 @@
"index": {
"auto_expand_replicas": "0-1",
"number_of_replicas": "0",
"number_of_shards": "1"
"number_of_shards": "1",
"mapping": {
"total_fields": { "limit": 1500 }
}
}
}
}