[Discover] [ES|QL] Prevent redundant requests when loading Discover sessions and toggling chart visibility (#206699)

## Summary

This PR prevents redundant Discover requests in ES|QL mode for the
following scenarios:
- Creating a new Discover session.
- Saving the current Discover session.
- Loading a saved Discover session.
- Toggling the Unified Histogram chart visibility.

It does so by addressing several underlying state related issues that
were triggering the redundant requests:
- Skipping the initial emission of `currentSuggestionContext` on Unified
Histogram mount, which immediately triggered a second fetch.
- Treating the Unified Histogram `table` prop the same as other props
which affect Lens suggestions (data view, query, columns), and deferring
updates to it until result fetching completes to avoid unnecessary
suggestion updates.
- Removing all auto-fetching behaviour from Unified Histogram and
instead relying solely on the consumer to control when fetching should
occur (including the initial fetch).

Resolves #165192.

### Checklist

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] This was checked for breaking HTTP API changes, and any breaking
changes have been approved by the breaking-change committee. The
`release_note:breaking` label should be applied in these situations.
- [x] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [x] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: Matthias Wilhelm <matthias.wilhelm@elastic.co>
This commit is contained in:
Davis McPhee 2025-01-20 11:11:01 -04:00 committed by GitHub
parent 075806bffa
commit 1c7a823920
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 387 additions and 676 deletions

View file

@ -7,16 +7,13 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React, { useCallback, useMemo } from 'react';
import React, { useCallback } from 'react';
import { UnifiedHistogramContainer } from '@kbn/unified-histogram-plugin/public';
import { css } from '@emotion/react';
import useObservable from 'react-use/lib/useObservable';
import { ESQL_TABLE_TYPE } from '@kbn/data-plugin/common';
import type { Datatable } from '@kbn/expressions-plugin/common';
import { useDiscoverHistogram } from './use_discover_histogram';
import { type DiscoverMainContentProps, DiscoverMainContent } from './discover_main_content';
import { useAppStateSelector } from '../../state_management/discover_app_state_container';
import { FetchStatus } from '../../../types';
import { useIsEsqlMode } from '../../hooks/use_is_esql_mode';
export interface DiscoverHistogramLayoutProps extends DiscoverMainContentProps {
@ -44,7 +41,6 @@ export const DiscoverHistogramLayout = ({
hideChart,
});
const datatable = useObservable(dataState.data$.documents$);
const renderCustomChartToggleActions = useCallback(
() =>
React.isValidElement(panelsToggle)
@ -53,23 +49,6 @@ export const DiscoverHistogramLayout = ({
[panelsToggle]
);
const table: Datatable | undefined = useMemo(() => {
if (
isEsqlMode &&
datatable &&
[FetchStatus.PARTIAL, FetchStatus.COMPLETE].includes(datatable.fetchStatus)
) {
return {
type: 'datatable' as 'datatable',
rows: datatable.result!.map((r) => r.raw),
columns: datatable.esqlQueryColumns || [],
meta: {
type: ESQL_TABLE_TYPE,
},
};
}
}, [datatable, isEsqlMode]);
// Initialized when the first search has been requested or
// when in ES|QL mode since search sessions are not supported
if (!searchSessionId && !isEsqlMode) {
@ -81,7 +60,6 @@ export const DiscoverHistogramLayout = ({
{...unifiedHistogramProps}
searchSessionId={searchSessionId}
requestAdapter={dataState.inspectorAdapters.requests}
table={table}
container={container}
css={histogramLayoutCss}
renderCustomChartToggleActions={renderCustomChartToggleActions}

View file

@ -161,7 +161,6 @@ describe('useDiscoverHistogram', () => {
const { hook } = await renderUseDiscoverHistogram();
const params = hook.result.current.getCreationOptions();
expect(params?.localStorageKeyPrefix).toBe('discover');
expect(params?.disableAutoFetching).toBe(true);
expect(Object.keys(params?.initialState ?? {})).toEqual([
'chartHidden',
'timeInterval',
@ -398,8 +397,8 @@ describe('useDiscoverHistogram', () => {
});
});
describe('refetching', () => {
it('should call refetch when savedSearchFetch$ is triggered', async () => {
describe('fetching', () => {
it('should call fetch when savedSearchFetch$ is triggered', async () => {
const savedSearchFetch$ = new Subject<void>();
const stateContainer = getStateContainer();
stateContainer.dataState.fetchChart$ = savedSearchFetch$;
@ -408,33 +407,11 @@ describe('useDiscoverHistogram', () => {
act(() => {
hook.result.current.ref(api);
});
expect(api.refetch).toHaveBeenCalled();
expect(api.fetch).toHaveBeenCalled();
act(() => {
savedSearchFetch$.next();
});
expect(api.refetch).toHaveBeenCalledTimes(2);
});
it('should skip the next refetch when hideChart changes from true to false', async () => {
const savedSearchFetch$ = new Subject<void>();
const stateContainer = getStateContainer();
stateContainer.dataState.fetchChart$ = savedSearchFetch$;
const { hook, initialProps } = await renderUseDiscoverHistogram({ stateContainer });
const api = createMockUnifiedHistogramApi();
act(() => {
hook.result.current.ref(api);
});
expect(api.refetch).toHaveBeenCalled();
act(() => {
hook.rerender({ ...initialProps, hideChart: true });
});
act(() => {
hook.rerender({ ...initialProps, hideChart: false });
});
act(() => {
savedSearchFetch$.next();
});
expect(api.refetch).toHaveBeenCalledTimes(1);
expect(api.fetch).toHaveBeenCalledTimes(2);
});
});

View file

@ -19,7 +19,7 @@ import {
UnifiedHistogramVisContext,
} from '@kbn/unified-histogram-plugin/public';
import { isEqual } from 'lodash';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
debounceTime,
distinctUntilChanged,
@ -28,13 +28,15 @@ import {
merge,
Observable,
pairwise,
skip,
startWith,
} from 'rxjs';
import useObservable from 'react-use/lib/useObservable';
import type { RequestAdapter } from '@kbn/inspector-plugin/common';
import type { DatatableColumn } from '@kbn/expressions-plugin/common';
import type { Datatable, DatatableColumn } from '@kbn/expressions-plugin/common';
import type { SavedSearch } from '@kbn/saved-search-plugin/common';
import { Filter } from '@kbn/es-query';
import { Filter, isOfAggregateQueryType } from '@kbn/es-query';
import { ESQL_TABLE_TYPE } from '@kbn/data-plugin/common';
import { useDiscoverCustomization } from '../../../../customizations';
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
import { FetchStatus } from '../../../types';
@ -240,6 +242,7 @@ export const useDiscoverHistogram = ({
dataView: esqlDataView,
query: esqlQuery,
columns: esqlColumns,
table,
} = useObservable(esqlFetchComplete$, initialEsqlProps);
useEffect(() => {
@ -249,9 +252,7 @@ export const useDiscoverHistogram = ({
}
const fetchStart = stateContainer.dataState.fetchChart$.subscribe(() => {
if (!skipRefetch.current) {
setIsSuggestionLoading(true);
}
setIsSuggestionLoading(true);
});
const fetchComplete = esqlFetchComplete$.subscribe(() => {
setIsSuggestionLoading(false);
@ -267,18 +268,6 @@ export const useDiscoverHistogram = ({
* Data fetching
*/
const skipRefetch = useRef<boolean>();
// Skip refetching when showing the chart since Lens will
// automatically fetch when the chart is shown
useEffect(() => {
if (skipRefetch.current === undefined) {
skipRefetch.current = false;
} else {
skipRefetch.current = !hideChart;
}
}, [hideChart]);
// Handle unified histogram refetching
useEffect(() => {
if (!unifiedHistogram) {
@ -304,18 +293,14 @@ export const useDiscoverHistogram = ({
}
const subscription = fetchChart$.subscribe((source) => {
if (!skipRefetch.current) {
if (source === 'discover') addLog('Unified Histogram - Discover refetch');
if (source === 'lens') addLog('Unified Histogram - Lens suggestion refetch');
unifiedHistogram.refetch();
}
skipRefetch.current = false;
if (source === 'discover') addLog('Unified Histogram - Discover refetch');
if (source === 'lens') addLog('Unified Histogram - Lens suggestion refetch');
unifiedHistogram.fetch();
});
// triggering the initial request for total hits hook
if (!isEsqlMode && !skipRefetch.current) {
unifiedHistogram.refetch();
// triggering the initial chart request
if (!isEsqlMode) {
unifiedHistogram.fetch();
}
return () => {
@ -397,6 +382,7 @@ export const useDiscoverHistogram = ({
timeRange: timeRangeMemoized,
relativeTimeRange,
columns: isEsqlMode ? esqlColumns : undefined,
table: isEsqlMode ? table : undefined,
onFilter: histogramCustomization?.onFilter,
onBrushEnd: histogramCustomization?.onBrushEnd,
withDefaultActions: histogramCustomization?.withDefaultActions,
@ -495,7 +481,10 @@ const createTotalHitsObservable = (state$?: Observable<UnifiedHistogramState>) =
const createCurrentSuggestionObservable = (state$: Observable<UnifiedHistogramState>) => {
return state$.pipe(
map((state) => state.currentSuggestionContext),
distinctUntilChanged(isEqual)
distinctUntilChanged(isEqual),
// Skip the first emission since it's the
// initial state and doesn't need a refetch
skip(1)
);
};
@ -507,11 +496,23 @@ function getUnifiedHistogramPropsForEsql({
savedSearch: SavedSearch;
}) {
const columns = documentsValue?.esqlQueryColumns || EMPTY_ESQL_COLUMNS;
const query = savedSearch.searchSource.getField('query');
const isEsqlMode = isOfAggregateQueryType(query);
const table: Datatable | undefined =
isEsqlMode && documentsValue?.result
? {
type: 'datatable',
rows: documentsValue.result.map((r) => r.raw),
columns,
meta: { type: ESQL_TABLE_TYPE },
}
: undefined;
const nextProps = {
dataView: savedSearch.searchSource.getField('index')!,
query: savedSearch.searchSource.getField('query'),
columns,
table,
};
addLog('[UnifiedHistogram] delayed next props for ES|QL', nextProps);

View file

@ -7,30 +7,15 @@ It manages its own state and data fetching, and can easily be dropped into pages
```tsx
// Import the container component
import {
UnifiedHistogramContainer,
} from '@kbn/unified-histogram-plugin/public';
import { UnifiedHistogramContainer } from '@kbn/unified-histogram-plugin/public';
// Import modules required for your application
import {
useServices,
useResizeRef,
useRequestParams,
MyLayout,
MyButton,
} from './my-modules';
import { useServices, useResizeRef, useRequestParams, MyLayout, MyButton } from './my-modules';
const services = useServices();
const resizeRef = useResizeRef();
const {
dataView,
query,
filters,
timeRange,
relativeTimeRange,
searchSessionId,
requestAdapter,
} = useRequestParams();
const { dataView, query, filters, timeRange, relativeTimeRange, searchSessionId, requestAdapter } =
useRequestParams();
return (
<UnifiedHistogramContainer
@ -71,7 +56,7 @@ import {
useCallbacks,
useRequestParams,
useStateParams,
useManualRefetch,
useFetch,
MyLayout,
MyButton,
} from './my-modules';
@ -97,20 +82,13 @@ const getCreationOptions = useCallback(() => ({
// Optionally provide a local storage key prefix to save parts of the state,
// such as the chart hidden state and top panel height, to local storage
localStorageKeyPrefix: 'myApp',
// By default Unified Histogram will automatically refetch based on certain
// state changes, such as chart hidden and request params, but this can be
// disabled in favour of manual fetching if preferred. Note that an initial
// request is always triggered when first initialized, and when the chart
// changes from hidden to visible, Lens will automatically trigger a refetch
// regardless of what this property is set to
disableAutoFetching: true,
// Customize the initial state in order to override the defaults
initialState: { chartHidden, breakdownField },
}), [...]);
// Manually refetch if disableAutoFetching is true
useManualRefetch(() => {
unifiedHistogram?.refetch();
// Trigger a fetch, must be called on init to render the chart
useFetch(() => {
unifiedHistogram?.fetch();
});
// Update the Unified Histogram state when our state params change

View file

@ -19,7 +19,7 @@ import type { ReactWrapper } from 'enzyme';
import { unifiedHistogramServicesMock } from '../__mocks__/services';
import { getLensVisMock } from '../__mocks__/lens_vis';
import { searchSourceInstanceMock } from '@kbn/data-plugin/common/search/search_source/mocks';
import { of } from 'rxjs';
import { Subject, of } from 'rxjs';
import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield';
import { dataViewMock } from '../__mocks__/data_view';
import { BreakdownFieldSelector } from './breakdown_field_selector';
@ -135,6 +135,7 @@ async function mountComponent({
withDefaultActions: undefined,
isChartAvailable: checkChartAvailability({ chart, dataView, isPlainRecord }),
renderCustomChartToggleActions: customToggle ? () => customToggle : undefined,
input$: new Subject(),
};
let instance: ReactWrapper = {} as ReactWrapper;
@ -142,6 +143,7 @@ async function mountComponent({
instance = mountWithIntl(<Chart {...props} />);
// wait for initial async loading to complete
await new Promise((r) => setTimeout(r, 0));
props.input$?.next({ type: 'fetch' });
instance.update();
});
return instance;

View file

@ -18,11 +18,19 @@ import type {
LensEmbeddableInput,
LensEmbeddableOutput,
} from '@kbn/lens-plugin/public';
import type { DatatableColumn } from '@kbn/expressions-plugin/common';
import type {
Datatable,
DatatableColumn,
DefaultInspectorAdapters,
} from '@kbn/expressions-plugin/common';
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
import type { TimeRange } from '@kbn/es-query';
import { PublishingSubject } from '@kbn/presentation-publishing';
import { RequestStatus } from '@kbn/inspector-plugin/public';
import { IKibanaSearchResponse } from '@kbn/search-types';
import { estypes } from '@elastic/elasticsearch';
import { Histogram } from './histogram';
import type {
import {
UnifiedHistogramSuggestionContext,
UnifiedHistogramBreakdownContext,
UnifiedHistogramChartContext,
@ -33,6 +41,7 @@ import type {
UnifiedHistogramInputMessage,
UnifiedHistogramRequestContext,
UnifiedHistogramServices,
UnifiedHistogramBucketInterval,
} from '../types';
import { UnifiedHistogramSuggestionType } from '../types';
import { BreakdownFieldSelector } from './breakdown_field_selector';
@ -41,11 +50,14 @@ import { useTotalHits } from './hooks/use_total_hits';
import { useChartStyles } from './hooks/use_chart_styles';
import { useChartActions } from './hooks/use_chart_actions';
import { ChartConfigPanel } from './chart_config_panel';
import { useRefetch } from './hooks/use_refetch';
import { useFetch } from './hooks/use_fetch';
import { useEditVisualization } from './hooks/use_edit_visualization';
import { LensVisService } from '../services/lens_vis_service';
import type { UseRequestParamsResult } from '../hooks/use_request_params';
import { removeTablesFromLensAttributes } from '../utils/lens_vis_from_table';
import { useLensProps } from './hooks/use_lens_props';
import { useStableCallback } from '../hooks/use_stable_callback';
import { buildBucketInterval } from './utils/build_bucket_interval';
export interface ChartProps {
abortController?: AbortController;
@ -64,7 +76,6 @@ export interface ChartProps {
breakdown?: UnifiedHistogramBreakdownContext;
renderCustomChartToggleActions?: () => ReactElement | undefined;
appendHistogram?: ReactElement;
disableAutoFetching?: boolean;
disableTriggers?: LensEmbeddableInput['disableTriggers'];
disabledActions?: LensEmbeddableInput['disabledActions'];
input$?: UnifiedHistogramInput$;
@ -99,7 +110,6 @@ export function Chart({
isPlainRecord,
renderCustomChartToggleActions,
appendHistogram,
disableAutoFetching,
disableTriggers,
disabledActions,
input$: originalInput$,
@ -140,20 +150,9 @@ export function Chart({
const { filters, query, getTimeRange, updateTimeRange, relativeTimeRange } = requestParams;
const refetch$ = useRefetch({
dataView,
request,
hits,
chart,
chartVisible,
breakdown,
filters,
query,
relativeTimeRange,
currentSuggestion,
disableAutoFetching,
const fetch$ = useFetch({
input$,
beforeRefetch: updateTimeRange,
beforeFetch: updateTimeRange,
});
useTotalHits({
@ -165,11 +164,67 @@ export function Chart({
filters,
query,
getTimeRange,
refetch$,
fetch$,
onTotalHitsChange,
isPlainRecord,
});
const [bucketInterval, setBucketInterval] = useState<UnifiedHistogramBucketInterval>();
const onLoad = useStableCallback(
(
isLoading: boolean,
adapters: Partial<DefaultInspectorAdapters> | undefined,
dataLoadingSubject$?: PublishingSubject<boolean | undefined>
) => {
const lensRequest = adapters?.requests?.getRequests()[0];
const requestFailed = lensRequest?.status === RequestStatus.ERROR;
const json = lensRequest?.response?.json as
| IKibanaSearchResponse<estypes.SearchResponse>
| undefined;
const response = json?.rawResponse;
if (requestFailed) {
onTotalHitsChange?.(UnifiedHistogramFetchStatus.error, undefined);
onChartLoad?.({ adapters: adapters ?? {} });
return;
}
const adapterTables = adapters?.tables?.tables;
const totalHits = computeTotalHits(hasLensSuggestions, adapterTables, isPlainRecord);
if (response?._shards?.failed || response?.timed_out) {
onTotalHitsChange?.(UnifiedHistogramFetchStatus.error, totalHits);
} else {
onTotalHitsChange?.(
isLoading ? UnifiedHistogramFetchStatus.loading : UnifiedHistogramFetchStatus.complete,
totalHits ?? hits?.total
);
}
if (response) {
const newBucketInterval = buildBucketInterval({
data: services.data,
dataView,
timeInterval: chart?.timeInterval,
timeRange: getTimeRange(),
response,
});
setBucketInterval(newBucketInterval);
}
onChartLoad?.({ adapters: adapters ?? {}, dataLoading$: dataLoadingSubject$ });
}
);
const lensPropsContext = useLensProps({
request,
getTimeRange,
fetch$,
visContext,
onLoad,
});
const { chartToolbarCss, histogramCss } = useChartStyles(chartVisible);
const onSuggestionContextEdit = useCallback(
@ -356,26 +411,24 @@ export function Chart({
<EuiProgress size="xs" color="accent" position="absolute" />
</EuiDelayRender>
)}
<HistogramMemoized
abortController={abortController}
services={services}
dataView={dataView}
request={request}
hits={hits}
chart={chart}
getTimeRange={getTimeRange}
refetch$={refetch$}
visContext={visContext}
isPlainRecord={isPlainRecord}
disableTriggers={disableTriggers}
disabledActions={disabledActions}
onTotalHitsChange={onTotalHitsChange}
hasLensSuggestions={hasLensSuggestions}
onChartLoad={onChartLoad}
onFilter={onFilter}
onBrushEnd={onBrushEnd}
withDefaultActions={withDefaultActions}
/>
{lensPropsContext && (
<HistogramMemoized
abortController={abortController}
services={services}
dataView={dataView}
chart={chart}
bucketInterval={bucketInterval}
getTimeRange={getTimeRange}
visContext={visContext}
isPlainRecord={isPlainRecord}
disableTriggers={disableTriggers}
disabledActions={disabledActions}
onFilter={onFilter}
onBrushEnd={onBrushEnd}
withDefaultActions={withDefaultActions}
{...lensPropsContext}
/>
)}
</section>
{appendHistogram}
</EuiFlexItem>
@ -405,3 +458,30 @@ export function Chart({
</EuiFlexGroup>
);
}
const computeTotalHits = (
hasLensSuggestions: boolean,
adapterTables:
| {
[key: string]: Datatable;
}
| undefined,
isPlainRecord?: boolean
) => {
if (isPlainRecord && hasLensSuggestions) {
return Object.values(adapterTables ?? {})?.[0]?.rows?.length;
} else if (isPlainRecord && !hasLensSuggestions) {
// ES|QL histogram case
const rows = Object.values(adapterTables ?? {})?.[0]?.rows;
if (!rows) {
return undefined;
}
let rowsCount = 0;
rows.forEach((r) => {
rowsCount += r.results;
});
return rowsCount;
} else {
return adapterTables?.unifiedHistogram?.meta?.statistics?.totalCount;
}
};

View file

@ -8,23 +8,17 @@
*/
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { Histogram } from './histogram';
import { Histogram, HistogramProps } from './histogram';
import React from 'react';
import { BehaviorSubject, Subject } from 'rxjs';
import { unifiedHistogramServicesMock } from '../__mocks__/services';
import { getLensVisMock } from '../__mocks__/lens_vis';
import { dataViewWithTimefieldMock } from '../__mocks__/data_view_with_timefield';
import { createDefaultInspectorAdapters } from '@kbn/expressions-plugin/common';
import { UnifiedHistogramFetchStatus, UnifiedHistogramInput$ } from '../types';
import { UnifiedHistogramInput$ } from '../types';
import { act } from 'react-dom/test-utils';
import * as buildBucketInterval from './utils/build_bucket_interval';
import * as useTimeRange from './hooks/use_time_range';
import { RequestStatus } from '@kbn/inspector-plugin/public';
import { getLensProps } from './hooks/use_lens_props';
const mockBucketInterval = { description: '1 minute', scale: undefined, scaled: false };
jest.spyOn(buildBucketInterval, 'buildBucketInterval').mockReturnValue(mockBucketInterval);
jest.spyOn(useTimeRange, 'useTimeRange');
import { getLensProps, useLensProps } from './hooks/use_lens_props';
const getMockLensAttributes = async () => {
const query = {
@ -44,46 +38,48 @@ const getMockLensAttributes = async () => {
).visContext;
};
type CombinedProps = Omit<HistogramProps, 'requestData' | 'lensProps'> &
Parameters<typeof useLensProps>[0];
async function mountComponent(isPlainRecord = false, hasLensSuggestions = false) {
const services = unifiedHistogramServicesMock;
services.data.query.timefilter.timefilter.getAbsoluteTime = () => {
return { from: '2020-05-14T11:05:13.590', to: '2020-05-14T11:20:13.590' };
};
const timefilterUpdateHandler = jest.fn();
const refetch$: UnifiedHistogramInput$ = new Subject();
const props = {
const fetch$: UnifiedHistogramInput$ = new Subject();
const props: CombinedProps = {
services: unifiedHistogramServicesMock,
request: {
searchSessionId: '123',
},
hasLensSuggestions,
isPlainRecord,
hits: {
status: UnifiedHistogramFetchStatus.loading,
total: undefined,
},
chart: {
hidden: false,
timeInterval: 'auto',
},
timefilterUpdateHandler,
dataView: dataViewWithTimefieldMock,
getTimeRange: () => ({
from: '2020-05-14T11:05:13.590',
to: '2020-05-14T11:20:13.590',
}),
refetch$,
fetch$,
visContext: (await getMockLensAttributes())!,
onTotalHitsChange: jest.fn(),
onChartLoad: jest.fn(),
onLoad: jest.fn(),
withDefaultActions: undefined,
};
return {
props,
component: mountWithIntl(<Histogram {...props} />),
const Wrapper = (wrapperProps: CombinedProps) => {
const lensPropsContext = useLensProps(wrapperProps);
return lensPropsContext ? <Histogram {...wrapperProps} {...lensPropsContext} /> : null;
};
const component = mountWithIntl(<Wrapper {...props} />);
act(() => {
fetch$?.next({ type: 'fetch' });
});
return { props, fetch$, component: component.update() };
}
describe('Histogram', () => {
@ -92,13 +88,13 @@ describe('Histogram', () => {
expect(component.find('[data-test-subj="unifiedHistogramChart"]').exists()).toBe(true);
});
it('should only update lens.EmbeddableComponent props when refetch$ is triggered', async () => {
const { component, props } = await mountComponent();
it('should only update lens.EmbeddableComponent props when fetch$ is triggered', async () => {
const { component, props, fetch$ } = await mountComponent();
const embeddable = unifiedHistogramServicesMock.lens.EmbeddableComponent;
expect(component.find(embeddable).exists()).toBe(true);
let lensProps = component.find(embeddable).props();
const originalProps = getLensProps({
searchSessionId: props.request.searchSessionId,
searchSessionId: props.request?.searchSessionId,
getTimeRange: props.getTimeRange,
attributes: (await getMockLensAttributes())!.attributes,
onLoad: lensProps.onLoad!,
@ -108,7 +104,7 @@ describe('Histogram', () => {
lensProps = component.find(embeddable).props();
expect(lensProps).toMatchObject(expect.objectContaining(originalProps));
await act(async () => {
props.refetch$.next({ type: 'refetch' });
fetch$.next({ type: 'fetch' });
});
component.update();
lensProps = component.find(embeddable).props();
@ -174,27 +170,11 @@ describe('Histogram', () => {
.mockReturnValue([{ response: { json: { rawResponse } } } as any]);
const dataLoading$ = new BehaviorSubject<boolean | undefined>(false);
onLoad(true, undefined, dataLoading$);
expect(props.onTotalHitsChange).toHaveBeenLastCalledWith(
UnifiedHistogramFetchStatus.loading,
undefined
);
expect(props.onChartLoad).toHaveBeenLastCalledWith({ adapters: {}, dataLoading$ });
expect(buildBucketInterval.buildBucketInterval).not.toHaveBeenCalled();
expect(useTimeRange.useTimeRange).toHaveBeenLastCalledWith(
expect.objectContaining({ bucketInterval: undefined })
);
expect(props.onLoad).toHaveBeenLastCalledWith(true, undefined, dataLoading$);
act(() => {
onLoad?.(false, adapters, dataLoading$);
});
expect(props.onTotalHitsChange).toHaveBeenLastCalledWith(
UnifiedHistogramFetchStatus.complete,
100
);
expect(props.onChartLoad).toHaveBeenLastCalledWith({ adapters, dataLoading$ });
expect(buildBucketInterval.buildBucketInterval).toHaveBeenCalled();
expect(useTimeRange.useTimeRange).toHaveBeenLastCalledWith(
expect.objectContaining({ bucketInterval: mockBucketInterval })
);
expect(props.onLoad).toHaveBeenLastCalledWith(false, adapters, dataLoading$);
});
it('should execute onLoad correctly when the request has a failure status', async () => {
@ -206,11 +186,7 @@ describe('Histogram', () => {
.spyOn(adapters.requests, 'getRequests')
.mockReturnValue([{ status: RequestStatus.ERROR } as any]);
onLoad?.(false, adapters);
expect(props.onTotalHitsChange).toHaveBeenLastCalledWith(
UnifiedHistogramFetchStatus.error,
undefined
);
expect(props.onChartLoad).toHaveBeenLastCalledWith({ adapters });
expect(props.onLoad).toHaveBeenLastCalledWith(false, adapters);
});
it('should execute onLoad correctly when the response has shard failures', async () => {
@ -239,11 +215,7 @@ describe('Histogram', () => {
act(() => {
onLoad?.(false, adapters);
});
expect(props.onTotalHitsChange).toHaveBeenLastCalledWith(
UnifiedHistogramFetchStatus.error,
100
);
expect(props.onChartLoad).toHaveBeenLastCalledWith({ adapters });
expect(props.onLoad).toHaveBeenLastCalledWith(false, adapters);
});
it('should execute onLoad correctly for textbased language and no Lens suggestions', async () => {
@ -275,11 +247,7 @@ describe('Histogram', () => {
act(() => {
onLoad?.(false, adapters);
});
expect(props.onTotalHitsChange).toHaveBeenLastCalledWith(
UnifiedHistogramFetchStatus.complete,
20
);
expect(props.onChartLoad).toHaveBeenLastCalledWith({ adapters });
expect(props.onLoad).toHaveBeenLastCalledWith(false, adapters);
});
it('should execute onLoad correctly for textbased language and Lens suggestions', async () => {
@ -311,10 +279,6 @@ describe('Histogram', () => {
act(() => {
onLoad?.(false, adapters);
});
expect(props.onTotalHitsChange).toHaveBeenLastCalledWith(
UnifiedHistogramFetchStatus.complete,
2
);
expect(props.onChartLoad).toHaveBeenLastCalledWith({ adapters });
expect(props.onLoad).toHaveBeenLastCalledWith(false, adapters);
});
});

View file

@ -9,101 +9,54 @@
import { useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import React, { useState } from 'react';
import React from 'react';
import type { DataView } from '@kbn/data-views-plugin/public';
import type { DefaultInspectorAdapters, Datatable } from '@kbn/expressions-plugin/common';
import type { IKibanaSearchResponse } from '@kbn/search-types';
import type { estypes } from '@elastic/elasticsearch';
import type { TimeRange } from '@kbn/es-query';
import type { EmbeddableComponentProps, LensEmbeddableInput } from '@kbn/lens-plugin/public';
import { RequestStatus } from '@kbn/inspector-plugin/public';
import type { Observable } from 'rxjs';
import { PublishingSubject } from '@kbn/presentation-publishing';
import {
import type {
UnifiedHistogramBucketInterval,
UnifiedHistogramChartContext,
UnifiedHistogramFetchStatus,
UnifiedHistogramHitsContext,
UnifiedHistogramChartLoadEvent,
UnifiedHistogramRequestContext,
UnifiedHistogramServices,
UnifiedHistogramInputMessage,
UnifiedHistogramVisContext,
} from '../types';
import { buildBucketInterval } from './utils/build_bucket_interval';
import { useTimeRange } from './hooks/use_time_range';
import { useStableCallback } from '../hooks/use_stable_callback';
import { useLensProps } from './hooks/use_lens_props';
import type { LensProps } from './hooks/use_lens_props';
export interface HistogramProps {
abortController?: AbortController;
services: UnifiedHistogramServices;
dataView: DataView;
request?: UnifiedHistogramRequestContext;
hits?: UnifiedHistogramHitsContext;
chart: UnifiedHistogramChartContext;
bucketInterval?: UnifiedHistogramBucketInterval;
isPlainRecord?: boolean;
hasLensSuggestions: boolean;
getTimeRange: () => TimeRange;
refetch$: Observable<UnifiedHistogramInputMessage>;
requestData: string;
lensProps: LensProps;
visContext: UnifiedHistogramVisContext;
disableTriggers?: LensEmbeddableInput['disableTriggers'];
disabledActions?: LensEmbeddableInput['disabledActions'];
onTotalHitsChange?: (status: UnifiedHistogramFetchStatus, result?: number | Error) => void;
onChartLoad?: (event: UnifiedHistogramChartLoadEvent) => void;
onFilter?: LensEmbeddableInput['onFilter'];
onBrushEnd?: LensEmbeddableInput['onBrushEnd'];
withDefaultActions: EmbeddableComponentProps['withDefaultActions'];
}
const computeTotalHits = (
hasLensSuggestions: boolean,
adapterTables:
| {
[key: string]: Datatable;
}
| undefined,
isPlainRecord?: boolean
) => {
if (isPlainRecord && hasLensSuggestions) {
return Object.values(adapterTables ?? {})?.[0]?.rows?.length;
} else if (isPlainRecord && !hasLensSuggestions) {
// ES|QL histogram case
const rows = Object.values(adapterTables ?? {})?.[0]?.rows;
if (!rows) {
return undefined;
}
let rowsCount = 0;
rows.forEach((r) => {
rowsCount += r.results;
});
return rowsCount;
} else {
return adapterTables?.unifiedHistogram?.meta?.statistics?.totalCount;
}
};
export function Histogram({
services: { data, lens, uiSettings },
services: { lens, uiSettings },
dataView,
request,
hits,
chart: { timeInterval },
bucketInterval,
isPlainRecord,
hasLensSuggestions,
getTimeRange,
refetch$,
requestData,
lensProps,
visContext,
disableTriggers,
disabledActions,
onTotalHitsChange,
onChartLoad,
onFilter,
onBrushEnd,
withDefaultActions,
abortController,
}: HistogramProps) {
const [bucketInterval, setBucketInterval] = useState<UnifiedHistogramBucketInterval>();
const { timeRangeText, timeRangeDisplay } = useTimeRange({
uiSettings,
bucketInterval,
@ -113,63 +66,8 @@ export function Histogram({
timeField: dataView.timeFieldName,
});
const { attributes } = visContext;
const onLoad = useStableCallback(
(
isLoading: boolean,
adapters: Partial<DefaultInspectorAdapters> | undefined,
dataLoading$?: PublishingSubject<boolean | undefined>
) => {
const lensRequest = adapters?.requests?.getRequests()[0];
const requestFailed = lensRequest?.status === RequestStatus.ERROR;
const json = lensRequest?.response?.json as
| IKibanaSearchResponse<estypes.SearchResponse>
| undefined;
const response = json?.rawResponse;
if (requestFailed) {
onTotalHitsChange?.(UnifiedHistogramFetchStatus.error, undefined);
onChartLoad?.({ adapters: adapters ?? {} });
return;
}
const adapterTables = adapters?.tables?.tables;
const totalHits = computeTotalHits(hasLensSuggestions, adapterTables, isPlainRecord);
if (response?._shards?.failed || response?.timed_out) {
onTotalHitsChange?.(UnifiedHistogramFetchStatus.error, totalHits);
} else {
onTotalHitsChange?.(
isLoading ? UnifiedHistogramFetchStatus.loading : UnifiedHistogramFetchStatus.complete,
totalHits ?? hits?.total
);
}
if (response) {
const newBucketInterval = buildBucketInterval({
data,
dataView,
timeInterval,
timeRange: getTimeRange(),
response,
});
setBucketInterval(newBucketInterval);
}
onChartLoad?.({ adapters: adapters ?? {}, dataLoading$ });
}
);
const { lensProps, requestData } = useLensProps({
request,
getTimeRange,
refetch$,
visContext,
onLoad,
});
const { euiTheme } = useEuiTheme();
const boxShadow = `0 2px 2px -1px ${euiTheme.colors.mediumShade},
0 1px 5px -2px ${euiTheme.colors.mediumShade}`;
const chartCss = css`

View file

@ -0,0 +1,35 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { useFetch } from './use_fetch';
import { renderHook } from '@testing-library/react';
import { UnifiedHistogramInput$ } from '../../types';
import { Subject } from 'rxjs';
describe('useFetch', () => {
const getDeps: () => {
input$: UnifiedHistogramInput$;
beforeFetch: () => void;
} = () => ({
input$: new Subject(),
beforeFetch: () => {},
});
it('should trigger the fetch observable when the input$ observable is triggered', () => {
const originalDeps = getDeps();
const hook = renderHook((deps) => useFetch(deps), {
initialProps: originalDeps,
});
const fetch = jest.fn();
hook.result.current.subscribe(fetch);
expect(fetch).not.toHaveBeenCalled();
originalDeps.input$.next({ type: 'fetch' });
expect(fetch).toHaveBeenCalledTimes(1);
});
});

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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { useMemo } from 'react';
import { filter, share, tap } from 'rxjs';
import { UnifiedHistogramInput$ } from '../../types';
export const useFetch = ({
input$,
beforeFetch,
}: {
input$: UnifiedHistogramInput$;
beforeFetch: () => void;
}) => {
return useMemo(
() =>
input$.pipe(
filter((message) => message.type === 'fetch'),
tap(beforeFetch),
share()
),
[beforeFetch, input$]
);
};

View file

@ -17,7 +17,7 @@ import { getLensProps, useLensProps } from './use_lens_props';
describe('useLensProps', () => {
it('should return lens props', async () => {
const getTimeRange = jest.fn();
const refetch$ = new Subject<UnifiedHistogramInputMessage>();
const fetch$ = new Subject<UnifiedHistogramInputMessage>();
const onLoad = jest.fn();
const query = {
language: 'kuery',
@ -41,12 +41,15 @@ describe('useLensProps', () => {
adapter: undefined,
},
getTimeRange,
refetch$,
fetch$,
visContext: attributesContext!,
onLoad,
});
});
expect(lensProps.result.current.lensProps).toEqual(
act(() => {
fetch$.next({ type: 'fetch' });
});
expect(lensProps.result.current?.lensProps).toEqual(
getLensProps({
searchSessionId: 'id',
getTimeRange,
@ -58,7 +61,7 @@ describe('useLensProps', () => {
it('should return lens props for text based languages', async () => {
const getTimeRange = jest.fn();
const refetch$ = new Subject<UnifiedHistogramInputMessage>();
const fetch$ = new Subject<UnifiedHistogramInputMessage>();
const onLoad = jest.fn();
const query = {
language: 'kuery',
@ -82,12 +85,15 @@ describe('useLensProps', () => {
adapter: undefined,
},
getTimeRange,
refetch$,
fetch$,
visContext: attributesContext!,
onLoad,
});
});
expect(lensProps.result.current.lensProps).toEqual(
act(() => {
fetch$.next({ type: 'fetch' });
});
expect(lensProps.result.current?.lensProps).toEqual(
getLensProps({
searchSessionId: 'id',
getTimeRange,
@ -97,9 +103,9 @@ describe('useLensProps', () => {
);
});
it('should only update lens props when refetch$ is triggered', async () => {
it('should only return lens props after fetch$ is triggered', async () => {
const getTimeRange = jest.fn();
const refetch$ = new Subject<UnifiedHistogramInputMessage>();
const fetch$ = new Subject<UnifiedHistogramInputMessage>();
const onLoad = jest.fn();
const query = {
language: 'kuery',
@ -122,7 +128,7 @@ describe('useLensProps', () => {
adapter: undefined,
},
getTimeRange,
refetch$,
fetch$,
visContext: attributesContext!,
onLoad,
};
@ -132,12 +138,12 @@ describe('useLensProps', () => {
},
{ initialProps: lensProps }
);
const originalProps = hook.result.current;
expect(hook.result.current).toEqual(undefined);
hook.rerender({ ...lensProps, request: { searchSessionId: '456', adapter: undefined } });
expect(hook.result.current).toEqual(originalProps);
expect(hook.result.current).toEqual(undefined);
act(() => {
refetch$.next({ type: 'refetch' });
fetch$.next({ type: 'fetch' });
});
expect(hook.result.current).not.toEqual(originalProps);
expect(hook.result.current).not.toEqual(undefined);
});
});

View file

@ -10,7 +10,7 @@
import type { TimeRange } from '@kbn/data-plugin/common';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import type { DefaultInspectorAdapters } from '@kbn/expressions-plugin/common';
import type { TypedLensByValueInput } from '@kbn/lens-plugin/public';
import type { EmbeddableComponentProps, TypedLensByValueInput } from '@kbn/lens-plugin/public';
import { useCallback, useEffect, useState } from 'react';
import type { Observable } from 'rxjs';
import type {
@ -20,21 +20,38 @@ import type {
} from '../../types';
import { useStableCallback } from '../../hooks/use_stable_callback';
export type LensProps = Pick<
EmbeddableComponentProps,
| 'id'
| 'viewMode'
| 'timeRange'
| 'attributes'
| 'noPadding'
| 'searchSessionId'
| 'executionContext'
| 'onLoad'
>;
export const useLensProps = ({
request,
getTimeRange,
refetch$,
fetch$,
visContext,
onLoad,
}: {
request?: UnifiedHistogramRequestContext;
getTimeRange: () => TimeRange;
refetch$: Observable<UnifiedHistogramInputMessage>;
visContext: UnifiedHistogramVisContext;
fetch$: Observable<UnifiedHistogramInputMessage>;
visContext?: UnifiedHistogramVisContext;
onLoad: (isLoading: boolean, adapters: Partial<DefaultInspectorAdapters> | undefined) => void;
}) => {
const buildLensProps = useCallback(() => {
if (!visContext) {
return;
}
const { attributes, requestData } = visContext;
return {
requestData: JSON.stringify(requestData),
lensProps: getLensProps({
@ -46,13 +63,14 @@ export const useLensProps = ({
};
}, [visContext, getTimeRange, onLoad, request?.searchSessionId]);
const [lensPropsContext, setLensPropsContext] = useState(buildLensProps());
// Initialize with undefined to avoid rendering Lens until a fetch has been triggered
const [lensPropsContext, setLensPropsContext] = useState<ReturnType<typeof buildLensProps>>();
const updateLensPropsContext = useStableCallback(() => setLensPropsContext(buildLensProps()));
useEffect(() => {
const subscription = refetch$.subscribe(updateLensPropsContext);
const subscription = fetch$.subscribe(updateLensPropsContext);
return () => subscription.unsubscribe();
}, [refetch$, updateLensPropsContext]);
}, [fetch$, updateLensPropsContext]);
return lensPropsContext;
};
@ -67,7 +85,7 @@ export const getLensProps = ({
getTimeRange: () => TimeRange;
attributes: TypedLensByValueInput['attributes'];
onLoad: (isLoading: boolean, adapters: Partial<DefaultInspectorAdapters> | undefined) => void;
}) => ({
}): LensProps => ({
id: 'unifiedHistogramLensComponent',
viewMode: ViewMode.VIEW,
timeRange: getTimeRange(),

View file

@ -1,86 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { useRefetch } from './use_refetch';
import { DataView } from '@kbn/data-views-plugin/common';
import { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
import { renderHook } from '@testing-library/react';
import {
UnifiedHistogramBreakdownContext,
UnifiedHistogramChartContext,
UnifiedHistogramHitsContext,
UnifiedHistogramInput$,
UnifiedHistogramRequestContext,
} from '../../types';
import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield';
import { Subject } from 'rxjs';
describe('useRefetch', () => {
const getDeps: () => {
dataView: DataView;
request: UnifiedHistogramRequestContext | undefined;
hits: UnifiedHistogramHitsContext | undefined;
chart: UnifiedHistogramChartContext | undefined;
chartVisible: boolean;
breakdown: UnifiedHistogramBreakdownContext | undefined;
filters: Filter[];
query: Query | AggregateQuery;
relativeTimeRange: TimeRange;
input$: UnifiedHistogramInput$;
beforeRefetch: () => void;
} = () => ({
dataView: dataViewWithTimefieldMock,
request: undefined,
hits: undefined,
chart: undefined,
chartVisible: true,
breakdown: undefined,
filters: [],
query: { language: 'kuery', query: '' },
relativeTimeRange: { from: 'now-15m', to: 'now' },
input$: new Subject(),
beforeRefetch: () => {},
});
it('should trigger the refetch observable when any of the arguments change', () => {
const originalDeps = getDeps();
const hook = renderHook((deps) => useRefetch(deps), {
initialProps: originalDeps,
});
const refetch = jest.fn();
hook.result.current.subscribe(refetch);
hook.rerender({ ...originalDeps });
expect(refetch).not.toHaveBeenCalled();
hook.rerender({ ...originalDeps, chartVisible: false });
expect(refetch).toHaveBeenCalledTimes(1);
});
it('should not trigger the refetch observable when disableAutoFetching is true', () => {
const originalDeps = { ...getDeps(), disableAutoFetching: true };
const hook = renderHook((deps) => useRefetch(deps), {
initialProps: originalDeps,
});
const refetch = jest.fn();
hook.result.current.subscribe(refetch);
hook.rerender({ ...originalDeps, chartVisible: false });
expect(refetch).not.toHaveBeenCalled();
});
it('should trigger the refetch observable when the input$ observable is triggered', () => {
const originalDeps = { ...getDeps(), disableAutoFetching: true };
const hook = renderHook((deps) => useRefetch(deps), {
initialProps: originalDeps,
});
const refetch = jest.fn();
hook.result.current.subscribe(refetch);
expect(refetch).not.toHaveBeenCalled();
originalDeps.input$.next({ type: 'refetch' });
expect(refetch).toHaveBeenCalledTimes(1);
});
});

View file

@ -1,145 +0,0 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { DataView } from '@kbn/data-views-plugin/common';
import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query';
import type { Suggestion } from '@kbn/lens-plugin/public';
import { cloneDeep, isEqual } from 'lodash';
import { useEffect, useMemo, useRef } from 'react';
import { filter, share, tap } from 'rxjs';
import {
UnifiedHistogramBreakdownContext,
UnifiedHistogramChartContext,
UnifiedHistogramHitsContext,
UnifiedHistogramInput$,
UnifiedHistogramRequestContext,
} from '../../types';
export const useRefetch = ({
dataView,
request,
hits,
chart,
chartVisible,
breakdown,
filters,
query,
relativeTimeRange,
currentSuggestion,
disableAutoFetching,
input$,
beforeRefetch,
}: {
dataView: DataView;
request: UnifiedHistogramRequestContext | undefined;
hits: UnifiedHistogramHitsContext | undefined;
chart: UnifiedHistogramChartContext | undefined;
chartVisible: boolean;
breakdown: UnifiedHistogramBreakdownContext | undefined;
filters: Filter[];
query: Query | AggregateQuery;
relativeTimeRange: TimeRange;
currentSuggestion?: Suggestion;
disableAutoFetching?: boolean;
input$: UnifiedHistogramInput$;
beforeRefetch: () => void;
}) => {
const refetchDeps = useRef<ReturnType<typeof getRefetchDeps>>();
// When the Unified Histogram props change, we must compare the current subset
// that should trigger a histogram refetch against the previous subset. If they
// are different, we must refetch the histogram to ensure it's up to date.
useEffect(() => {
// Skip if auto fetching if disabled
if (disableAutoFetching) {
return;
}
const newRefetchDeps = getRefetchDeps({
dataView,
request,
hits,
chart,
chartVisible,
breakdown,
filters,
query,
relativeTimeRange,
currentSuggestion,
});
if (!isEqual(refetchDeps.current, newRefetchDeps)) {
if (refetchDeps.current) {
input$.next({ type: 'refetch' });
}
refetchDeps.current = newRefetchDeps;
}
}, [
breakdown,
chart,
chartVisible,
currentSuggestion,
dataView,
disableAutoFetching,
filters,
hits,
input$,
query,
relativeTimeRange,
request,
]);
return useMemo(
() =>
input$.pipe(
filter((message) => message.type === 'refetch'),
tap(beforeRefetch),
share()
),
[beforeRefetch, input$]
);
};
const getRefetchDeps = ({
dataView,
request,
hits,
chart,
chartVisible,
breakdown,
filters,
query,
relativeTimeRange,
currentSuggestion,
}: {
dataView: DataView;
request: UnifiedHistogramRequestContext | undefined;
hits: UnifiedHistogramHitsContext | undefined;
chart: UnifiedHistogramChartContext | undefined;
chartVisible: boolean;
breakdown: UnifiedHistogramBreakdownContext | undefined;
filters: Filter[];
query: Query | AggregateQuery;
relativeTimeRange: TimeRange;
currentSuggestion?: Suggestion;
}) =>
cloneDeep([
dataView.id,
request?.searchSessionId,
Boolean(hits),
chartVisible,
chart?.timeInterval,
Boolean(breakdown),
breakdown?.field,
filters,
query,
relativeTimeRange,
currentSuggestion?.visualizationId,
]);

View file

@ -28,7 +28,7 @@ jest.mock('react-use/lib/useDebounce', () => {
describe('useTotalHits', () => {
const timeRange = { from: 'now-15m', to: 'now' };
const refetch$: UnifiedHistogramInput$ = new Subject();
const fetch$: UnifiedHistogramInput$ = new Subject();
const getDeps = () => ({
services: {
data: dataPluginMock.createStartContract(),
@ -54,7 +54,7 @@ describe('useTotalHits', () => {
filters: [],
query: { query: '', language: 'kuery' },
getTimeRange: () => timeRange,
refetch$,
fetch$,
onTotalHitsChange: jest.fn(),
});
@ -95,11 +95,11 @@ describe('useTotalHits', () => {
},
query,
filters,
refetch$,
fetch$,
onTotalHitsChange,
})
);
refetch$.next({ type: 'refetch' });
fetch$.next({ type: 'fetch' });
rerender();
expect(onTotalHitsChange).toBeCalledTimes(1);
expect(onTotalHitsChange).toBeCalledWith(UnifiedHistogramFetchStatus.loading, undefined);
@ -128,7 +128,7 @@ describe('useTotalHits', () => {
query: { esql: 'from test' },
};
const { rerender } = renderHook(() => useTotalHits(deps));
refetch$.next({ type: 'refetch' });
fetch$.next({ type: 'fetch' });
rerender();
expect(onTotalHitsChange).not.toHaveBeenCalled();
});
@ -153,7 +153,7 @@ describe('useTotalHits', () => {
expect(fetchSpy).not.toHaveBeenCalled();
});
it('should not fetch if refetch$ is not triggered', async () => {
it('should not fetch if fetch$ is not triggered', async () => {
const onTotalHitsChange = jest.fn();
const fetchSpy = jest.spyOn(searchSourceInstanceMock, 'fetch$').mockClear();
const setFieldSpy = jest.spyOn(searchSourceInstanceMock, 'setField').mockClear();
@ -165,14 +165,14 @@ describe('useTotalHits', () => {
expect(fetchSpy).toHaveBeenCalledTimes(0);
});
it('should fetch a second time if refetch$ is triggered', async () => {
it('should fetch a second time if fetch$ is triggered', async () => {
const abortSpy = jest.spyOn(AbortController.prototype, 'abort').mockClear();
const onTotalHitsChange = jest.fn();
const fetchSpy = jest.spyOn(searchSourceInstanceMock, 'fetch$').mockClear();
const setFieldSpy = jest.spyOn(searchSourceInstanceMock, 'setField').mockClear();
const options = { ...getDeps(), onTotalHitsChange };
const { rerender } = renderHook(() => useTotalHits(options));
refetch$.next({ type: 'refetch' });
fetch$.next({ type: 'fetch' });
rerender();
expect(onTotalHitsChange).toBeCalledTimes(1);
expect(setFieldSpy).toHaveBeenCalled();
@ -180,7 +180,7 @@ describe('useTotalHits', () => {
await waitFor(() => {
expect(onTotalHitsChange).toBeCalledTimes(2);
});
refetch$.next({ type: 'refetch' });
fetch$.next({ type: 'fetch' });
rerender();
expect(abortSpy).toHaveBeenCalled();
expect(onTotalHitsChange).toBeCalledTimes(3);
@ -199,7 +199,7 @@ describe('useTotalHits', () => {
.mockClear()
.mockReturnValue(throwError(() => error));
const { rerender } = renderHook(() => useTotalHits({ ...getDeps(), onTotalHitsChange }));
refetch$.next({ type: 'refetch' });
fetch$.next({ type: 'fetch' });
rerender();
await waitFor(() => {
expect(onTotalHitsChange).toBeCalledTimes(2);
@ -228,7 +228,7 @@ describe('useTotalHits', () => {
filters,
})
);
refetch$.next({ type: 'refetch' });
fetch$.next({ type: 'fetch' });
rerender();
expect(setOverwriteDataViewTypeSpy).toHaveBeenCalledWith(undefined);
expect(setFieldSpy).toHaveBeenCalledWith('filter', filters);

View file

@ -31,7 +31,7 @@ export const useTotalHits = ({
filters,
query,
getTimeRange,
refetch$,
fetch$,
onTotalHitsChange,
isPlainRecord,
}: {
@ -43,7 +43,7 @@ export const useTotalHits = ({
filters: Filter[];
query: Query | AggregateQuery;
getTimeRange: () => TimeRange;
refetch$: Observable<UnifiedHistogramInputMessage>;
fetch$: Observable<UnifiedHistogramInputMessage>;
onTotalHitsChange?: (status: UnifiedHistogramFetchStatus, result?: number | Error) => void;
isPlainRecord?: boolean;
}) => {
@ -65,9 +65,9 @@ export const useTotalHits = ({
});
useEffect(() => {
const subscription = refetch$.subscribe(fetch);
const subscription = fetch$.subscribe(fetch);
return () => subscription.unsubscribe();
}, [fetch, refetch$]);
}, [fetch, fetch$]);
};
const fetchTotalHits = async ({

View file

@ -46,17 +46,15 @@ describe('UnifiedHistogramContainer', () => {
expect(api).toBeDefined();
});
it('should trigger input$ when refetch is called', async () => {
it('should trigger input$ when fetch is called', async () => {
let api: UnifiedHistogramApi | undefined;
const setApi = (ref: UnifiedHistogramApi) => {
api = ref;
};
const getCreationOptions = jest.fn(() => ({ disableAutoFetching: true }));
const component = mountWithIntl(
<UnifiedHistogramContainer
services={unifiedHistogramServicesMock}
ref={setApi}
getCreationOptions={getCreationOptions}
dataView={dataViewWithTimefieldMock}
filters={[]}
query={{ language: 'kuery', query: '' }}
@ -72,9 +70,9 @@ describe('UnifiedHistogramContainer', () => {
const inputSpy = jest.fn();
input$?.subscribe(inputSpy);
act(() => {
api?.refetch();
api?.fetch();
});
expect(inputSpy).toHaveBeenCalledTimes(1);
expect(inputSpy).toHaveBeenCalledWith({ type: 'refetch' });
expect(inputSpy).toHaveBeenCalledWith({ type: 'fetch' });
});
});

View file

@ -30,10 +30,7 @@ import { topPanelHeightSelector } from './utils/state_selectors';
import { exportVisContext } from '../utils/external_vis_context';
import { getBreakdownField } from './utils/local_storage_utils';
type LayoutProps = Pick<
UnifiedHistogramLayoutProps,
'disableAutoFetching' | 'disableTriggers' | 'disabledActions'
>;
type LayoutProps = Pick<UnifiedHistogramLayoutProps, 'disableTriggers' | 'disabledActions'>;
/**
* The options used to initialize the container
@ -84,9 +81,9 @@ export type UnifiedHistogramContainerProps = {
*/
export type UnifiedHistogramApi = {
/**
* Manually trigger a refetch of the data
* Trigger a fetch of the data
*/
refetch: () => void;
fetch: () => void;
} & Pick<
UnifiedHistogramStateService,
'state$' | 'setChartHidden' | 'setTopPanelHeight' | 'setTimeInterval' | 'setTotalHits'
@ -112,7 +109,7 @@ export const UnifiedHistogramContainer = forwardRef<
const options = await getCreationOptions?.();
const apiHelper = await services.lens.stateHelperApi();
setLayoutProps(pick(options, 'disableAutoFetching', 'disableTriggers', 'disabledActions'));
setLayoutProps(pick(options, 'disableTriggers', 'disabledActions'));
setLocalStorageKeyPrefix(options?.localStorageKeyPrefix);
setStateService(createStateService({ services, ...options }));
setLensSuggestionsApi(() => apiHelper.suggestions);
@ -125,8 +122,8 @@ export const UnifiedHistogramContainer = forwardRef<
}
setApi({
refetch: () => {
input$.next({ type: 'refetch' });
fetch: () => {
input$.next({ type: 'fetch' });
},
...pick(
stateService,

View file

@ -119,10 +119,6 @@ export interface UnifiedHistogramLayoutProps extends PropsWithChildren<unknown>
* This element would replace the default chart toggle buttons
*/
renderCustomChartToggleActions?: () => ReactElement | undefined;
/**
* Disable automatic refetching based on props changes, and instead wait for a `refetch` message
*/
disableAutoFetching?: boolean;
/**
* Disable triggers for the Lens embeddable
*/
@ -219,7 +215,6 @@ export const UnifiedHistogramLayout = ({
container,
topPanelHeight,
renderCustomChartToggleActions,
disableAutoFetching,
disableTriggers,
disabledActions,
lensSuggestionsApi,
@ -359,7 +354,6 @@ export const UnifiedHistogramLayout = ({
breakdown={breakdown}
renderCustomChartToggleActions={renderCustomChartToggleActions}
appendHistogram={chartSpacer}
disableAutoFetching={disableAutoFetching}
disableTriggers={disableTriggers}
disabledActions={disabledActions}
input$={input$}

View file

@ -17,7 +17,7 @@ export const createMockUnifiedHistogramApi = () => {
setTopPanelHeight: jest.fn(),
setTimeInterval: jest.fn(),
setTotalHits: jest.fn(),
refetch: jest.fn(),
fetch: jest.fn(),
};
return api;
};

View file

@ -126,16 +126,16 @@ export interface UnifiedHistogramBreakdownContext {
}
/**
* Message to refetch the chart and total hits
* Message to fetch the chart and total hits
*/
export interface UnifiedHistogramRefetchMessage {
type: 'refetch';
export interface UnifiedHistogramFetchMessage {
type: 'fetch';
}
/**
* Unified histogram input message
*/
export type UnifiedHistogramInputMessage = UnifiedHistogramRefetchMessage;
export type UnifiedHistogramInputMessage = UnifiedHistogramFetchMessage;
/**
* Unified histogram input observable

View file

@ -65,11 +65,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
.getEntries()
.filter((entry: any) => ['fetch', 'xmlhttprequest'].includes(entry.initiatorType))
);
const result = requests.filter((entry) =>
entry.name.endsWith(`/internal/search/${endpoint}`)
);
const count = result.length;
if (count !== searchCount) {
log.warning('Request count differs:', result);
@ -80,18 +78,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
);
};
const waitForLoadingToFinish = async () => {
await header.waitUntilLoadingHasFinished();
await discover.waitForDocTableLoadingComplete();
await elasticChart.canvasExists();
};
const expectSearches = async (type: 'ese' | 'esql', expected: number, cb: Function) => {
await expectSearchCount(type, 0);
await cb();
await expectSearchCount(type, expected);
};
const waitForLoadingToFinish = async () => {
await header.waitUntilLoadingHasFinished();
await discover.waitForDocTableLoadingComplete();
await elasticChart.canvasExists();
};
const getSharedTests = ({
type,
savedSearch,
@ -99,7 +97,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
query2,
savedSearchesRequests,
setQuery,
expectedRequests = 2,
}: {
type: 'ese' | 'esql';
savedSearch: string;
@ -107,10 +104,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
query2: string;
savedSearchesRequests?: number;
setQuery: (query: string) => Promise<void>;
expectedRequests?: number;
expectedRefreshRequest?: number;
}) => {
it(`should send no more than ${expectedRequests} search requests (documents + chart) on page load`, async () => {
it(`should send 2 search requests (documents + chart) on page load`, async () => {
if (type === 'ese') {
await browser.refresh();
}
@ -118,29 +113,29 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
performance.setResourceTimingBufferSize(Number.MAX_SAFE_INTEGER);
});
if (type === 'esql') {
await expectSearches(type, expectedRequests, async () => {
await expectSearches(type, 2, async () => {
await queryBar.clickQuerySubmitButton();
});
} else {
await expectSearchCount(type, expectedRequests);
await expectSearchCount(type, 2);
}
});
it(`should send no more than ${expectedRequests} requests (documents + chart) when refreshing`, async () => {
await expectSearches(type, expectedRequests, async () => {
it(`should send 2 requests (documents + chart) when refreshing`, async () => {
await expectSearches(type, 2, async () => {
await queryBar.clickQuerySubmitButton();
});
});
it(`should send no more than ${expectedRequests} requests (documents + chart) when changing the query`, async () => {
await expectSearches(type, expectedRequests, async () => {
it(`should send 2 requests (documents + chart) when changing the query`, async () => {
await expectSearches(type, 2, async () => {
await setQuery(query1);
await queryBar.clickQuerySubmitButton();
});
});
it(`should send no more than ${expectedRequests} requests (documents + chart) when changing the time range`, async () => {
await expectSearches(type, expectedRequests, async () => {
it(`should send 2 requests (documents + chart) when changing the time range`, async () => {
await expectSearches(type, 2, async () => {
await timePicker.setAbsoluteRange(
'Sep 21, 2015 @ 06:31:44.000',
'Sep 23, 2015 @ 00:00:00.000'
@ -156,6 +151,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await discover.toggleChartVisibility();
});
});
it(`should send a request for chart data when toggling the chart visibility after a time range change`, async () => {
// hide chart
await discover.toggleChartVisibility();
@ -170,7 +166,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
});
it(`should send ${savedSearchesRequests} requests for saved search changes`, async () => {
const actualSavedSearchRequests = savedSearchesRequests ?? 2;
it(`should send no more than ${actualSavedSearchRequests} requests for saved search changes`, async () => {
await setQuery(query1);
await queryBar.clickQuerySubmitButton();
await timePicker.setAbsoluteRange(
@ -178,42 +176,29 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
'Sep 23, 2015 @ 00:00:00.000'
);
await waitForLoadingToFinish();
const actualExpectedRequests = savedSearchesRequests ?? expectedRequests;
log.debug('Creating saved search');
await expectSearches(
type,
type === 'esql' ? actualExpectedRequests + 2 : actualExpectedRequests,
async () => {
await discover.saveSearch(savedSearch);
}
);
await expectSearches(type, actualSavedSearchRequests, async () => {
await discover.saveSearch(savedSearch);
});
log.debug('Resetting saved search');
await setQuery(query2);
await queryBar.clickQuerySubmitButton();
await waitForLoadingToFinish();
await expectSearches(type, actualExpectedRequests, async () => {
await expectSearches(type, 2, async () => {
await discover.revertUnsavedChanges();
});
log.debug('Clearing saved search');
await expectSearches(
type,
type === 'esql' ? actualExpectedRequests + 1 : actualExpectedRequests,
async () => {
await testSubjects.click('discoverNewButton');
if (type === 'esql') {
await queryBar.clickQuerySubmitButton();
}
await waitForLoadingToFinish();
await expectSearches(type, actualSavedSearchRequests, async () => {
await testSubjects.click('discoverNewButton');
if (type === 'esql') {
await queryBar.clickQuerySubmitButton();
}
);
await waitForLoadingToFinish();
});
log.debug('Loading saved search');
await expectSearches(
type,
type === 'esql' ? actualExpectedRequests + 2 : actualExpectedRequests,
async () => {
await discover.loadSavedSearch(savedSearch);
}
);
await expectSearches(type, actualSavedSearchRequests, async () => {
await discover.loadSavedSearch(savedSearch);
});
});
};
@ -233,7 +218,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
setQuery: (query) => queryBar.setQuery(query),
});
it('should send no more than 2 requests (documents + chart) when adding a filter', async () => {
it('should send 2 requests (documents + chart) when adding a filter', async () => {
await expectSearches(type, 2, async () => {
await filterBar.addFilter({
field: 'extension',
@ -243,39 +228,41 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
});
});
it('should send no more than 2 requests (documents + chart) when sorting', async () => {
it('should send 2 requests (documents + chart) when sorting', async () => {
await expectSearches(type, 2, async () => {
await discover.clickFieldSort('@timestamp', 'Sort Old-New');
});
});
it('should send no more than 2 requests (documents + chart) when changing to a breakdown field without an other bucket', async () => {
it('should send 2 requests (documents + chart) when changing to a breakdown field without an other bucket', async () => {
await expectSearches(type, 2, async () => {
await discover.chooseBreakdownField('type');
});
});
it('should send no more than 3 requests (documents + chart + other bucket) when changing to a breakdown field with an other bucket', async () => {
it('should send 3 requests (documents + chart + other bucket) when changing to a breakdown field with an other bucket', async () => {
await testSubjects.click('discoverNewButton');
await expectSearches(type, 3, async () => {
await discover.chooseBreakdownField('extension.raw');
});
});
it('should send no more than 2 requests (documents + chart) when changing the chart interval', async () => {
it('should send 2 requests (documents + chart) when changing the chart interval', async () => {
await expectSearches(type, 2, async () => {
await discover.setChartInterval('Day');
});
});
it('should send no more than 2 requests (documents + chart) when changing the data view', async () => {
it('should send 2 requests (documents + chart) when changing the data view', async () => {
await expectSearches(type, 2, async () => {
await discover.selectIndexPattern('long-window-logstash-*');
});
});
});
describe('ES|QL mode', () => {
const type = 'esql';
before(async () => {
await kibanaServer.uiSettings.update({
'discover:searchOnPageLoad': false,
@ -293,9 +280,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
savedSearch: 'esql test',
query1: 'from logstash-* | where bytes > 1000 ',
query2: 'from logstash-* | where bytes < 2000 ',
savedSearchesRequests: 2,
savedSearchesRequests: 3,
setQuery: (query) => monacoEditor.setCodeEditorValue(query),
expectedRequests: 2,
});
});
});