diff --git a/x-pack/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/plugins/lens/public/app_plugin/app.test.tsx index 5e38cb49114e9..5aec6b02c057c 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -10,7 +10,7 @@ import { ReactWrapper } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { App } from './app'; import { LensAppProps, LensAppServices } from './types'; -import { EditorFrameInstance } from '../types'; +import { EditorFrameInstance, EditorFrameProps } from '../types'; import { Document } from '../persistence'; import { DOC_TYPE } from '../../common'; import { mount } from 'enzyme'; @@ -44,6 +44,8 @@ import { import { LensAttributeService } from '../lens_attribute_service'; import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; import { EmbeddableStateTransfer } from '../../../../../src/plugins/embeddable/public'; +import { NativeRenderer } from '../native_renderer'; +import moment from 'moment'; jest.mock('../editor_frame_service/editor_frame/expression_helpers'); jest.mock('src/core/public'); @@ -144,6 +146,11 @@ function createMockTimefilter() { return unsubscribe; }, }), + calculateBounds: jest.fn(() => ({ + min: moment('2021-01-10T04:00:00.000Z'), + max: moment('2021-01-10T08:00:00.000Z'), + })), + getBounds: jest.fn(() => timeFilter), getRefreshInterval: () => {}, getRefreshIntervalDefaults: () => {}, getAutoRefreshFetch$: () => ({ @@ -233,6 +240,9 @@ describe('Lens App', () => { }), }, search: createMockSearchService(), + nowProvider: { + get: jest.fn(), + }, } as unknown) as DataPublicPluginStart, storage: { get: jest.fn(), @@ -306,8 +316,8 @@ describe('Lens App', () => { />, Object { "dateRange": Object { - "fromDate": "now-7d", - "toDate": "now", + "fromDate": "2021-01-10T04:00:00.000Z", + "toDate": "2021-01-10T08:00:00.000Z", }, "doc": undefined, "filters": Array [], @@ -350,7 +360,7 @@ describe('Lens App', () => { expect(frame.mount).toHaveBeenCalledWith( expect.any(Element), expect.objectContaining({ - dateRange: { fromDate: 'now-7d', toDate: 'now' }, + dateRange: { fromDate: '2021-01-10T04:00:00.000Z', toDate: '2021-01-10T08:00:00.000Z' }, query: { query: '', language: 'kuery' }, filters: [pinnedFilter], }) @@ -1008,7 +1018,7 @@ describe('Lens App', () => { expect(frame.mount).toHaveBeenCalledWith( expect.any(Element), expect.objectContaining({ - dateRange: { fromDate: 'now-7d', toDate: 'now' }, + dateRange: { fromDate: '2021-01-10T04:00:00.000Z', toDate: '2021-01-10T08:00:00.000Z' }, query: { query: '', language: 'kuery' }, }) ); @@ -1055,7 +1065,11 @@ describe('Lens App', () => { }); it('updates the editor frame when the user changes query or time in the search bar', () => { - const { component, frame } = mountWith({}); + const { component, frame, services } = mountWith({}); + (services.data.query.timefilter.timefilter.calculateBounds as jest.Mock).mockReturnValue({ + min: moment('2021-01-09T04:00:00.000Z'), + max: moment('2021-01-09T08:00:00.000Z'), + }); act(() => component.find(TopNavMenu).prop('onQuerySubmit')!({ dateRange: { from: 'now-14d', to: 'now-7d' }, @@ -1071,10 +1085,14 @@ describe('Lens App', () => { }), {} ); + expect(services.data.query.timefilter.timefilter.setTime).toHaveBeenCalledWith({ + from: 'now-14d', + to: 'now-7d', + }); expect(frame.mount).toHaveBeenCalledWith( expect.any(Element), expect.objectContaining({ - dateRange: { fromDate: 'now-14d', toDate: 'now-7d' }, + dateRange: { fromDate: '2021-01-09T04:00:00.000Z', toDate: '2021-01-09T08:00:00.000Z' }, query: { query: 'new', language: 'lucene' }, }) ); @@ -1237,6 +1255,34 @@ describe('Lens App', () => { ); }); + it('clears all existing unpinned filters when the active saved query is cleared', () => { + const { component, frame, services } = mountWith({}); + act(() => + component.find(TopNavMenu).prop('onQuerySubmit')!({ + dateRange: { from: 'now-14d', to: 'now-7d' }, + query: { query: 'new', language: 'lucene' }, + }) + ); + const indexPattern = ({ id: 'index1' } as unknown) as IIndexPattern; + const field = ({ name: 'myfield' } as unknown) as IFieldType; + const pinnedField = ({ name: 'pinnedField' } as unknown) as IFieldType; + const unpinned = esFilters.buildExistsFilter(field, indexPattern); + const pinned = esFilters.buildExistsFilter(pinnedField, indexPattern); + FilterManager.setFiltersStore([pinned], esFilters.FilterStateStore.GLOBAL_STATE); + act(() => services.data.query.filterManager.setFilters([pinned, unpinned])); + component.update(); + act(() => component.find(TopNavMenu).prop('onClearSavedQuery')!()); + component.update(); + expect(frame.mount).toHaveBeenLastCalledWith( + expect.any(Element), + expect.objectContaining({ + filters: [pinned], + }) + ); + }); + }); + + describe('search session id management', () => { it('updates the searchSessionId when the query is updated', () => { const { component, frame } = mountWith({}); act(() => { @@ -1263,12 +1309,12 @@ describe('Lens App', () => { expect(frame.mount).toHaveBeenCalledWith( expect.any(Element), expect.objectContaining({ - searchSessionId: `sessionId-1`, + searchSessionId: `sessionId-2`, }) ); }); - it('clears all existing unpinned filters when the active saved query is cleared', () => { + it('updates the searchSessionId when the active saved query is cleared', () => { const { component, frame, services } = mountWith({}); act(() => component.find(TopNavMenu).prop('onQuerySubmit')!({ @@ -1286,31 +1332,67 @@ describe('Lens App', () => { component.update(); act(() => component.find(TopNavMenu).prop('onClearSavedQuery')!()); component.update(); - expect(frame.mount).toHaveBeenLastCalledWith( + expect(frame.mount).toHaveBeenCalledWith( expect.any(Element), expect.objectContaining({ - filters: [pinned], + searchSessionId: `sessionId-2`, }) ); }); - it('updates the searchSessionId when the active saved query is cleared', () => { - const { component, frame, services } = mountWith({}); - act(() => - component.find(TopNavMenu).prop('onQuerySubmit')!({ - dateRange: { from: 'now-14d', to: 'now-7d' }, - query: { query: 'new', language: 'lucene' }, + const mockUpdate = { + filterableIndexPatterns: [], + doc: { + title: '', + description: '', + visualizationType: '', + state: { + datasourceStates: {}, + visualization: {}, + filters: [], + query: { query: '', language: 'lucene' }, + }, + references: [], + }, + isSaveable: true, + activeData: undefined, + }; + + it('does not update the searchSessionId when the state changes', () => { + const { component, frame } = mountWith({}); + act(() => { + (component.find(NativeRenderer).prop('nativeProps') as EditorFrameProps).onChange( + mockUpdate + ); + }); + component.update(); + expect(frame.mount).not.toHaveBeenCalledWith( + expect.any(Element), + expect.objectContaining({ + searchSessionId: `sessionId-2`, }) ); - const indexPattern = ({ id: 'index1' } as unknown) as IIndexPattern; - const field = ({ name: 'myfield' } as unknown) as IFieldType; - const pinnedField = ({ name: 'pinnedField' } as unknown) as IFieldType; - const unpinned = esFilters.buildExistsFilter(field, indexPattern); - const pinned = esFilters.buildExistsFilter(pinnedField, indexPattern); - FilterManager.setFiltersStore([pinned], esFilters.FilterStateStore.GLOBAL_STATE); - act(() => services.data.query.filterManager.setFilters([pinned, unpinned])); - component.update(); - act(() => component.find(TopNavMenu).prop('onClearSavedQuery')!()); + }); + + it('does update the searchSessionId when the state changes and too much time passed', () => { + const { component, frame, services } = mountWith({}); + + // time range is 100,000ms ago to 30,000ms ago (that's a lag of 30 percent) + (services.data.nowProvider.get as jest.Mock).mockReturnValue(new Date(Date.now() - 30000)); + (services.data.query.timefilter.timefilter.getTime as jest.Mock).mockReturnValue({ + from: 'now-2m', + to: 'now', + }); + (services.data.query.timefilter.timefilter.getBounds as jest.Mock).mockReturnValue({ + min: moment(Date.now() - 100000), + max: moment(Date.now() - 30000), + }); + + act(() => { + (component.find(NativeRenderer).prop('nativeProps') as EditorFrameProps).onChange( + mockUpdate + ); + }); component.update(); expect(frame.mount).toHaveBeenCalledWith( expect.any(Element), @@ -1319,6 +1401,34 @@ describe('Lens App', () => { }) ); }); + + it('does not update the searchSessionId when the state changes and too little time has passed', () => { + const { component, frame, services } = mountWith({}); + + // time range is 100,000ms ago to 300ms ago (that's a lag of .3 percent, not enough to trigger a session update) + (services.data.nowProvider.get as jest.Mock).mockReturnValue(new Date(Date.now() - 300)); + (services.data.query.timefilter.timefilter.getTime as jest.Mock).mockReturnValue({ + from: 'now-2m', + to: 'now', + }); + (services.data.query.timefilter.timefilter.getBounds as jest.Mock).mockReturnValue({ + min: moment(Date.now() - 100000), + max: moment(Date.now() - 300), + }); + + act(() => { + (component.find(NativeRenderer).prop('nativeProps') as EditorFrameProps).onChange( + mockUpdate + ); + }); + component.update(); + expect(frame.mount).not.toHaveBeenCalledWith( + expect.any(Element), + expect.objectContaining({ + searchSessionId: `sessionId-2`, + }) + ); + }); }); describe('showing a confirm message when leaving', () => { diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index 3f10cb341105c..28e1f6da60742 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -7,7 +7,7 @@ import './app.scss'; import _ from 'lodash'; -import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { NotificationsStart } from 'kibana/public'; import { EuiBreadcrumb } from '@elastic/eui'; @@ -39,6 +39,7 @@ import { LensByReferenceInput, LensEmbeddableInput, } from '../editor_frame_service/embeddable/embeddable'; +import { useTimeRange } from './time_range'; export function App({ history, @@ -107,9 +108,11 @@ export function App({ state.searchSessionId, ]); - // Need a stable reference for the frame component of the dateRange - const { from: fromDate, to: toDate } = data.query.timefilter.timefilter.getTime(); - const currentDateRange = useMemo(() => ({ fromDate, toDate }), [fromDate, toDate]); + const { resolvedDateRange, from: fromDate, to: toDate } = useTimeRange( + data, + state.lastKnownDoc, + setState + ); const onError = useCallback( (e: { message: string }) => @@ -658,7 +661,7 @@ export function App({ render={editorFrame.mount} nativeProps={{ searchSessionId: state.searchSessionId, - dateRange: currentDateRange, + dateRange: resolvedDateRange, query: state.query, filters: state.filters, savedQuery: state.savedQuery, @@ -670,7 +673,7 @@ export function App({ if (isSaveable !== state.isSaveable) { setState((s) => ({ ...s, isSaveable })); } - if (!_.isEqual(state.persistedDoc, doc)) { + if (!_.isEqual(state.persistedDoc, doc) && !_.isEqual(state.lastKnownDoc, doc)) { setState((s) => ({ ...s, lastKnownDoc: doc })); } if (!_.isEqual(state.activeData, activeData)) { diff --git a/x-pack/plugins/lens/public/app_plugin/time_range.ts b/x-pack/plugins/lens/public/app_plugin/time_range.ts new file mode 100644 index 0000000000000..5a9f2d5ab9e90 --- /dev/null +++ b/x-pack/plugins/lens/public/app_plugin/time_range.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import './app.scss'; + +import _ from 'lodash'; +import moment from 'moment'; +import { useEffect, useMemo } from 'react'; +import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; +import { LensAppState } from './types'; +import { Document } from '../persistence'; + +function containsDynamicMath(dateMathString: string) { + return dateMathString.includes('now'); +} + +const TIME_LAG_PERCENTAGE_LIMIT = 0.02; + +/** + * Fetches the current global time range from data plugin and restarts session + * if the fixed "now" parameter is diverging too much from the actual current time. + * @param data data plugin contract to manage current now value, time range and session + * @param lastKnownDoc Current state of the editor + * @param setState state setter for Lens app state + */ +export function useTimeRange( + data: DataPublicPluginStart, + lastKnownDoc: Document | undefined, + setState: React.Dispatch> +) { + const timefilter = data.query.timefilter.timefilter; + const { from, to } = data.query.timefilter.timefilter.getTime(); + const currentNow = data.nowProvider.get(); + + // Need a stable reference for the frame component of the dateRange + const resolvedDateRange = useMemo(() => { + const { min, max } = timefilter.calculateBounds({ + from, + to, + }); + return { fromDate: min?.toISOString() || from, toDate: max?.toISOString() || to }; + // recalculate current date range if current "now" value changes because calculateBounds + // depends on it internally + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [timefilter, currentNow, from, to]); + + useEffect(() => { + const unresolvedTimeRange = timefilter.getTime(); + if ( + !containsDynamicMath(unresolvedTimeRange.from) && + !containsDynamicMath(unresolvedTimeRange.to) + ) { + return; + } + + const { min, max } = timefilter.getBounds(); + + if (!min || !max) { + // bounds not fully specified, bailing out + return; + } + + // calculate length of currently configured range in ms + const timeRangeLength = moment.duration(max.diff(min)).asMilliseconds(); + + // calculate lag of managed "now" for date math + const nowDiff = Date.now() - data.nowProvider.get().valueOf(); + + // if the lag is signifcant, start a new session to clear the cache + if (nowDiff > timeRangeLength * TIME_LAG_PERCENTAGE_LIMIT) { + setState((s) => ({ + ...s, + searchSessionId: data.search.session.start(), + })); + } + }, [data.nowProvider, data.search.session, timefilter, lastKnownDoc, setState]); + + return { resolvedDateRange, from, to }; +} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx index 2cb815596d8b9..f908d16afe470 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx @@ -252,7 +252,6 @@ export function EditorFrame(props: EditorFrameProps) { state.visualization, state.activeData, props.query, - props.dateRange, props.filters, props.savedQuery, state.title,